SeriesMetadataService.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. #pragma warning disable CS1591
  2. using System;
  3. using System.Collections.Generic;
  4. using System.Globalization;
  5. using System.Linq;
  6. using System.Threading;
  7. using System.Threading.Tasks;
  8. using MediaBrowser.Controller.Configuration;
  9. using MediaBrowser.Controller.Dto;
  10. using MediaBrowser.Controller.Entities;
  11. using MediaBrowser.Controller.Entities.TV;
  12. using MediaBrowser.Controller.Library;
  13. using MediaBrowser.Controller.Providers;
  14. using MediaBrowser.Model.Entities;
  15. using MediaBrowser.Model.Globalization;
  16. using MediaBrowser.Model.IO;
  17. using MediaBrowser.Providers.Manager;
  18. using Microsoft.Extensions.Logging;
  19. namespace MediaBrowser.Providers.TV
  20. {
  21. public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
  22. {
  23. private readonly ILocalizationManager _localizationManager;
  24. public SeriesMetadataService(
  25. IServerConfigurationManager serverConfigurationManager,
  26. ILogger<SeriesMetadataService> logger,
  27. IProviderManager providerManager,
  28. IFileSystem fileSystem,
  29. ILibraryManager libraryManager,
  30. ILocalizationManager localizationManager)
  31. : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
  32. {
  33. _localizationManager = localizationManager;
  34. }
  35. public override async Task<ItemUpdateType> RefreshMetadata(BaseItem item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)
  36. {
  37. if (item is Series series)
  38. {
  39. var seasons = series.GetRecursiveChildren(i => i is Season).ToList();
  40. foreach (var season in seasons)
  41. {
  42. var hasUpdate = refreshOptions != null && season.BeforeMetadataRefresh(refreshOptions.ReplaceAllMetadata);
  43. if (hasUpdate)
  44. {
  45. await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
  46. }
  47. }
  48. }
  49. return await base.RefreshMetadata(item, refreshOptions, cancellationToken).ConfigureAwait(false);
  50. }
  51. /// <inheritdoc />
  52. protected override async Task AfterMetadataRefresh(Series item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)
  53. {
  54. await base.AfterMetadataRefresh(item, refreshOptions, cancellationToken).ConfigureAwait(false);
  55. RemoveObsoleteEpisodes(item);
  56. RemoveObsoleteSeasons(item);
  57. await UpdateAndCreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false);
  58. }
  59. /// <inheritdoc />
  60. protected override bool IsFullLocalMetadata(Series item)
  61. {
  62. if (string.IsNullOrWhiteSpace(item.Overview))
  63. {
  64. return false;
  65. }
  66. if (!item.ProductionYear.HasValue)
  67. {
  68. return false;
  69. }
  70. return base.IsFullLocalMetadata(item);
  71. }
  72. /// <inheritdoc />
  73. protected override void MergeData(MetadataResult<Series> source, MetadataResult<Series> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
  74. {
  75. base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
  76. var sourceItem = source.Item;
  77. var targetItem = target.Item;
  78. var sourceSeasonNames = sourceItem.GetSeasonNames();
  79. var targetSeasonNames = targetItem.GetSeasonNames();
  80. if (replaceData)
  81. {
  82. foreach (var (number, name) in sourceSeasonNames)
  83. {
  84. targetItem.SetSeasonName(number, name);
  85. }
  86. }
  87. else if (!sourceSeasonNames.Keys.All(targetSeasonNames.ContainsKey))
  88. {
  89. var newSeasons = sourceSeasonNames.Where(s => !targetSeasonNames.ContainsKey(s.Key));
  90. foreach (var (number, name) in newSeasons)
  91. {
  92. targetItem.SetSeasonName(number, name);
  93. }
  94. }
  95. if (replaceData || string.IsNullOrEmpty(targetItem.AirTime))
  96. {
  97. targetItem.AirTime = sourceItem.AirTime;
  98. }
  99. if (replaceData || !targetItem.Status.HasValue)
  100. {
  101. targetItem.Status = sourceItem.Status;
  102. }
  103. if (replaceData || targetItem.AirDays is null || targetItem.AirDays.Length == 0)
  104. {
  105. targetItem.AirDays = sourceItem.AirDays;
  106. }
  107. }
  108. private void RemoveObsoleteSeasons(Series series)
  109. {
  110. // TODO Legacy. It's not really "physical" seasons as any virtual seasons are always converted to non-virtual in UpdateAndCreateSeasonsAsync.
  111. var physicalSeasonNumbers = new HashSet<int>();
  112. var virtualSeasons = new List<Season>();
  113. foreach (var existingSeason in series.Children.OfType<Season>())
  114. {
  115. if (existingSeason.LocationType != LocationType.Virtual && existingSeason.IndexNumber.HasValue)
  116. {
  117. physicalSeasonNumbers.Add(existingSeason.IndexNumber.Value);
  118. }
  119. else if (existingSeason.LocationType == LocationType.Virtual)
  120. {
  121. virtualSeasons.Add(existingSeason);
  122. }
  123. }
  124. foreach (var virtualSeason in virtualSeasons)
  125. {
  126. var seasonNumber = virtualSeason.IndexNumber;
  127. // If there's a physical season with the same number or no episodes in the season, delete it
  128. if ((seasonNumber.HasValue && physicalSeasonNumbers.Contains(seasonNumber.Value))
  129. || virtualSeason.GetEpisodes().Count == 0)
  130. {
  131. Logger.LogInformation("Removing virtual season {SeasonNumber} in series {SeriesName}", virtualSeason.IndexNumber, series.Name);
  132. LibraryManager.DeleteItem(
  133. virtualSeason,
  134. new DeleteOptions
  135. {
  136. DeleteFileLocation = true
  137. },
  138. false);
  139. }
  140. }
  141. }
  142. private void RemoveObsoleteEpisodes(Series series)
  143. {
  144. var episodes = series.GetEpisodes(null, new DtoOptions()).OfType<Episode>().ToList();
  145. var numberOfEpisodes = episodes.Count;
  146. // TODO: O(n^2), but can it be done faster without overcomplicating it?
  147. for (var i = 0; i < numberOfEpisodes; i++)
  148. {
  149. var currentEpisode = episodes[i];
  150. // The outer loop only examines virtual episodes
  151. if (!currentEpisode.IsVirtualItem)
  152. {
  153. continue;
  154. }
  155. // Virtual episodes without an episode number are practically orphaned and should be deleted
  156. if (!currentEpisode.IndexNumber.HasValue)
  157. {
  158. DeleteEpisode(currentEpisode);
  159. continue;
  160. }
  161. for (var j = i + 1; j < numberOfEpisodes; j++)
  162. {
  163. var comparisonEpisode = episodes[j];
  164. // The inner loop is only for "physical" episodes
  165. if (comparisonEpisode.IsVirtualItem
  166. || currentEpisode.ParentIndexNumber != comparisonEpisode.ParentIndexNumber
  167. || !comparisonEpisode.ContainsEpisodeNumber(currentEpisode.IndexNumber.Value))
  168. {
  169. continue;
  170. }
  171. DeleteEpisode(currentEpisode);
  172. break;
  173. }
  174. }
  175. }
  176. private void DeleteEpisode(Episode episode)
  177. {
  178. Logger.LogInformation(
  179. "Removing virtual episode S{SeasonNumber}E{EpisodeNumber} in series {SeriesName}",
  180. episode.ParentIndexNumber,
  181. episode.IndexNumber,
  182. episode.SeriesName);
  183. LibraryManager.DeleteItem(
  184. episode,
  185. new DeleteOptions
  186. {
  187. DeleteFileLocation = true
  188. },
  189. false);
  190. }
  191. /// <summary>
  192. /// Creates seasons for all episodes if they don't exist.
  193. /// If no season number can be determined, a dummy season will be created.
  194. /// Updates seasons names.
  195. /// </summary>
  196. /// <param name="series">The series.</param>
  197. /// <param name="cancellationToken">The cancellation token.</param>
  198. /// <returns>The async task.</returns>
  199. private async Task UpdateAndCreateSeasonsAsync(Series series, CancellationToken cancellationToken)
  200. {
  201. var seasonNames = series.GetSeasonNames();
  202. var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season);
  203. var seasons = seriesChildren.OfType<Season>().ToList();
  204. var uniqueSeasonNumbers = seriesChildren
  205. .OfType<Episode>()
  206. .Select(e => e.ParentIndexNumber >= 0 ? e.ParentIndexNumber : null)
  207. .Distinct();
  208. // Loop through the unique season numbers
  209. foreach (var seasonNumber in uniqueSeasonNumbers)
  210. {
  211. // Null season numbers will have a 'dummy' season created because seasons are always required.
  212. var existingSeason = seasons.FirstOrDefault(i => i.IndexNumber == seasonNumber);
  213. if (!seasonNumber.HasValue || !seasonNames.TryGetValue(seasonNumber.Value, out var seasonName))
  214. {
  215. seasonName = GetValidSeasonNameForSeries(series, null, seasonNumber);
  216. }
  217. if (existingSeason is null)
  218. {
  219. var season = await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false);
  220. series.AddChild(season);
  221. }
  222. else if (!existingSeason.LockedFields.Contains(MetadataField.Name) && !string.Equals(existingSeason.Name, seasonName, StringComparison.Ordinal))
  223. {
  224. existingSeason.Name = seasonName;
  225. await existingSeason.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
  226. }
  227. }
  228. }
  229. /// <summary>
  230. /// Creates a new season, adds it to the database by linking it to the [series] and refreshes the metadata.
  231. /// </summary>
  232. /// <param name="series">The series.</param>
  233. /// <param name="seasonName">The season name.</param>
  234. /// <param name="seasonNumber">The season number.</param>
  235. /// <param name="cancellationToken">The cancellation token.</param>
  236. /// <returns>The newly created season.</returns>
  237. private async Task<Season> CreateSeasonAsync(
  238. Series series,
  239. string? seasonName,
  240. int? seasonNumber,
  241. CancellationToken cancellationToken)
  242. {
  243. Logger.LogInformation("Creating Season {SeasonName} entry for {SeriesName}", seasonName, series.Name);
  244. var season = new Season
  245. {
  246. Name = seasonName,
  247. IndexNumber = seasonNumber,
  248. Id = LibraryManager.GetNewItemId(
  249. series.Id + (seasonNumber ?? -1).ToString(CultureInfo.InvariantCulture) + seasonName,
  250. typeof(Season)),
  251. IsVirtualItem = false,
  252. SeriesId = series.Id,
  253. SeriesName = series.Name
  254. };
  255. series.AddChild(season);
  256. await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken).ConfigureAwait(false);
  257. return season;
  258. }
  259. private string GetValidSeasonNameForSeries(Series series, string? seasonName, int? seasonNumber)
  260. {
  261. if (string.IsNullOrEmpty(seasonName))
  262. {
  263. seasonName = seasonNumber switch
  264. {
  265. null => _localizationManager.GetLocalizedString("NameSeasonUnknown"),
  266. 0 => LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName,
  267. _ => string.Format(CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("NameSeasonNumber"), seasonNumber.Value)
  268. };
  269. }
  270. return seasonName;
  271. }
  272. }
  273. }