From 68481905e848acdc5b24afba7e05dc707931b862 Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Wed, 25 Sep 2024 04:23:50 +0200 Subject: [PATCH] feat: merge versions upon refresh --- Shokofin/MergeVersions/MergeVersionManager.cs | 102 ++++++++++++++---- Shokofin/Providers/CustomEpisodeProvider.cs | 38 ++++--- Shokofin/Providers/CustomMovieProvider.cs | 49 +++++++++ Shokofin/Providers/CustomSeasonProvider.cs | 43 ++++++-- Shokofin/Providers/CustomSeriesProvider.cs | 32 +++++- 5 files changed, 215 insertions(+), 49 deletions(-) create mode 100644 Shokofin/Providers/CustomMovieProvider.cs diff --git a/Shokofin/MergeVersions/MergeVersionManager.cs b/Shokofin/MergeVersions/MergeVersionManager.cs index d51854cb..82c31d30 100644 --- a/Shokofin/MergeVersions/MergeVersionManager.cs +++ b/Shokofin/MergeVersions/MergeVersionManager.cs @@ -10,7 +10,9 @@ using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; using Shokofin.ExternalIds; +using Shokofin.Utils; namespace Shokofin.MergeVersions; @@ -24,25 +26,56 @@ namespace Shokofin.MergeVersions; /// https://github.com/danieladov/jellyfin-plugin-mergeversions public class MergeVersionsManager { + /// + /// Logger. + /// + private readonly ILogger _logger; + /// /// Library manager. Used to fetch items from the library. /// - private readonly ILibraryManager LibraryManager; + private readonly ILibraryManager _libraryManager; /// /// Shoko ID Lookup. Used to check if the plugin is enabled for the videos. /// - private readonly IIdLookup Lookup; + private readonly IIdLookup _lookup; + + /// + /// Used to clear the when the + /// event is ran. + /// + private readonly UsageTracker _usageTracker; + + /// + /// Used as a lock/guard to prevent multiple runs on the same video until + /// the event is ran. + /// + private readonly GuardedMemoryCache _runGuard; /// /// Used by the DI IoC to inject the needed interfaces. /// /// Library manager. /// Shoko ID Lookup. - public MergeVersionsManager(ILibraryManager libraryManager, IIdLookup lookup) + public MergeVersionsManager(ILogger logger, ILibraryManager libraryManager, IIdLookup lookup, UsageTracker usageTracker) + { + _logger = logger; + _libraryManager = libraryManager; + _lookup = lookup; + _usageTracker = usageTracker; + _usageTracker.Stalled += OnUsageTrackerStalled; + _runGuard = new(logger, new() { }, new() { }); + } + + ~MergeVersionsManager() + { + _usageTracker.Stalled -= OnUsageTrackerStalled; + } + + private void OnUsageTrackerStalled(object? sender, EventArgs e) { - LibraryManager = libraryManager; - Lookup = lookup; + _runGuard.Clear(); } #region Top Level @@ -120,6 +153,9 @@ public async Task SplitAndMergeAllEpisodes(IProgress? progress, Cancella public async Task SplitAllEpisodes(IProgress? progress, CancellationToken? cancellationToken) => await SplitVideos(GetEpisodesFromLibrary(), progress, cancellationToken); + public Task SplitAndMergeEpisodesByEpisodeId(string episodeId) + => _runGuard.GetOrCreateAsync($"episode:{episodeId}", () => SplitAndMergeVideos(GetEpisodesFromLibrary(episodeId))); + #endregion #region Movie Level @@ -130,6 +166,9 @@ public async Task SplitAndMergeAllMovies(IProgress? progress, Cancellati public async Task SplitAllMovies(IProgress? progress, CancellationToken? cancellationToken) => await SplitVideos(GetMoviesFromLibrary(), progress, cancellationToken); + public Task SplitAndMergeMoviesByEpisodeId(string movieId) + => _runGuard.GetOrCreateAsync($"movie:{movieId}", () => SplitAndMergeVideos(GetMoviesFromLibrary(movieId))); + #endregion #region Shared Methods @@ -140,7 +179,7 @@ public async Task SplitAllMovies(IProgress? progress, CancellationToken? /// Optional. The episode id if we want to filter to only movies with a given Shoko Episode ID. /// A list of all movies with the given set. public IReadOnlyList GetMoviesFromLibrary(string episodeId = "") - => LibraryManager + => _libraryManager .GetItemList(new() { IncludeItemTypes = [BaseItemKind.Movie], IsVirtualItem = false, @@ -148,7 +187,7 @@ public IReadOnlyList GetMoviesFromLibrary(string episodeId = "") HasAnyProviderId = new Dictionary { {ShokoEpisodeId.Name, episodeId } }, }) .OfType() - .Where(Lookup.IsEnabledForItem) + .Where(_lookup.IsEnabledForItem) .ToList(); /// @@ -157,7 +196,7 @@ public IReadOnlyList GetMoviesFromLibrary(string episodeId = "") /// Optional. The episode id if we want to filter to only episodes with a given Shoko Episode ID. /// A list of all episodes with a Shoko Episode ID set. public IReadOnlyList GetEpisodesFromLibrary(string episodeId = "") - => LibraryManager + => _libraryManager .GetItemList(new() { IncludeItemTypes = [BaseItemKind.Episode], HasAnyProviderId = new Dictionary { {ShokoEpisodeId.Name, episodeId } }, @@ -165,7 +204,7 @@ public IReadOnlyList GetEpisodesFromLibrary(string episodeId = "") Recursive = true, }) .Cast() - .Where(Lookup.IsEnabledForItem) + .Where(_lookup.IsEnabledForItem) .ToList(); /// @@ -175,7 +214,7 @@ public IReadOnlyList GetEpisodesFromLibrary(string episodeId = "") /// Cancellation token. /// An async task that will silently complete when the merging is /// complete. - public async Task SplitAndMergeVideos( + public async Task SplitAndMergeVideos( IReadOnlyList videos, IProgress? progress = null, CancellationToken? cancellationToken = null @@ -212,6 +251,8 @@ public async Task SplitAndMergeVideos( } progress?.Report(100); + + return true; } /// @@ -245,7 +286,7 @@ public async Task SplitVideos(IReadOnlyList videos, IProgress(IEnumerable input) where TVideo : Video + private async Task MergeVideos(IEnumerable input) where TVideo : Video { if (input is not IList videos) videos = input.ToList(); @@ -265,12 +306,12 @@ private static async Task MergeVideos(IEnumerable input) where T .First(); // Add any videos not already linked to the primary version to the list. - var alternateVersionsOfPrimary = primaryVersion.LinkedAlternateVersions - .ToList(); + var alternateVersionsOfPrimary = primaryVersion.LinkedAlternateVersions.ToList(); foreach (var video in videos.Where(v => !v.Id.Equals(primaryVersion.Id))) { video.SetPrimaryVersionId(primaryVersion.Id.ToString("N", CultureInfo.InvariantCulture)); if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, video.Path, StringComparison.OrdinalIgnoreCase))) { + _logger.LogTrace("Adding linked alternate version. (PrimaryVideo={PrimaryVideoId},Video={VideoId})", primaryVersion.Id, video.Id); alternateVersionsOfPrimary.Add(new() { Path = video.Path, ItemId = video.Id, @@ -278,20 +319,25 @@ private static async Task MergeVideos(IEnumerable input) where T } foreach (var linkedItem in video.LinkedAlternateVersions) { - if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase))) + if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase))) { + _logger.LogTrace("Adding linked alternate version. (PrimaryVideo={PrimaryVideoId},Video={VideoId},LinkedVideo={LinkedVideoId})", primaryVersion.Id, video.Id, linkedItem.ItemId); alternateVersionsOfPrimary.Add(linkedItem); + } } // Reset the linked alternate versions for the linked videos. - if (video.LinkedAlternateVersions.Length > 0) + if (video.LinkedAlternateVersions.Length > 0) { + _logger.LogTrace("Resetting linked alternate versions for video. (Video={VideoId})", video.Id); video.LinkedAlternateVersions = []; + } // Save the changes back to the repository. await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) .ConfigureAwait(false); } - primaryVersion.LinkedAlternateVersions = [.. alternateVersionsOfPrimary]; + _logger.LogTrace("Saving {Count} linked alternate versions. (PrimaryVideo={PrimaryVideoId})", alternateVersionsOfPrimary.Count, primaryVersion.Id); + primaryVersion.LinkedAlternateVersions = [.. alternateVersionsOfPrimary.OrderBy(i => i.Path)]; await primaryVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) .ConfigureAwait(false); } @@ -313,14 +359,21 @@ private async Task RemoveAlternateSources(TVideo video) where TVideo : V return; // Make sure the primary video still exists before we proceed. - if (LibraryManager.GetItemById(video.PrimaryVersionId) is not TVideo primaryVideo) + if (_libraryManager.GetItemById(video.PrimaryVersionId) is not TVideo primaryVideo) return; + + _logger.LogTrace("Primary video found for video. (PrimaryVideo={PrimaryVideoId},Video={VideoId})", primaryVideo.Id, video.Id); video = primaryVideo; } // Remove the link for every linked video. - foreach (var linkedVideo in video.GetLinkedAlternateVersions()) - { + var linkedAlternateVersions = video.GetLinkedAlternateVersions().ToList(); + _logger.LogTrace("Removing {Count} alternate sources for video. (Video={VideoId})", linkedAlternateVersions.Count, video.Id); + foreach (var linkedVideo in linkedAlternateVersions) { + if (string.IsNullOrEmpty(linkedVideo.PrimaryVersionId)) + continue; + + _logger.LogTrace("Removing alternate source. (PrimaryVideo={PrimaryVideoId},Video={VideoId})", linkedVideo.PrimaryVersionId, video.Id); linkedVideo.SetPrimaryVersionId(null); linkedVideo.LinkedAlternateVersions = []; await linkedVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) @@ -328,10 +381,13 @@ await linkedVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, Cancellat } // Remove the link for the primary video. - video.SetPrimaryVersionId(null); - video.LinkedAlternateVersions = []; - await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) - .ConfigureAwait(false); + if (!string.IsNullOrEmpty(video.PrimaryVersionId)) { + _logger.LogTrace("Removing primary source. (PrimaryVideo={PrimaryVideoId},Video={VideoId})", video.PrimaryVersionId, video.Id); + video.SetPrimaryVersionId(null); + video.LinkedAlternateVersions = []; + await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) + .ConfigureAwait(false); + } } #endregion Shared Methods diff --git a/Shokofin/Providers/CustomEpisodeProvider.cs b/Shokofin/Providers/CustomEpisodeProvider.cs index db244c41..973ae801 100644 --- a/Shokofin/Providers/CustomEpisodeProvider.cs +++ b/Shokofin/Providers/CustomEpisodeProvider.cs @@ -7,13 +7,15 @@ using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; using Shokofin.ExternalIds; +using Shokofin.MergeVersions; using Info = Shokofin.API.Info; namespace Shokofin.Providers; /// -/// The custom episode provider. Responsible for de-duplicating episodes. +/// The custom episode provider. Responsible for de-duplicating episodes, both +/// virtual and physical. /// /// /// This needs to be it's own class because of internal Jellyfin shenanigans @@ -24,32 +26,38 @@ public class CustomEpisodeProvider : ICustomMetadataProvider { public string Name => Plugin.MetadataProviderName; - private readonly ILogger Logger; + private readonly ILogger _logger; - private readonly IIdLookup Lookup; + private readonly ILibraryManager _libraryManager; - private readonly ILibraryManager LibraryManager; + private readonly MergeVersionsManager _mergeVersionsManager; - public CustomEpisodeProvider(ILogger logger, IIdLookup lookup, ILibraryManager libraryManager) + public CustomEpisodeProvider(ILogger logger, ILibraryManager libraryManager, MergeVersionsManager mergeVersionsManager) { - Logger = logger; - Lookup = lookup; - LibraryManager = libraryManager; + _logger = logger; + _libraryManager = libraryManager; + _mergeVersionsManager = mergeVersionsManager; } - public Task FetchAsync(Episode episode, MetadataRefreshOptions options, CancellationToken cancellationToken) + public async Task FetchAsync(Episode episode, MetadataRefreshOptions options, CancellationToken cancellationToken) { var series = episode.Series; if (series is null) - return Task.FromResult(ItemUpdateType.None); + return ItemUpdateType.None; - // Abort if we're unable to get the shoko episode id - if (episode.TryGetProviderId(ShokoEpisodeId.Name, out var episodeId)) + var itemUpdated = ItemUpdateType.None; + if (episode.TryGetProviderId(ShokoEpisodeId.Name, out var episodeId)) { using (Plugin.Instance.Tracker.Enter($"Providing custom info for Episode \"{episode.Name}\". (Path=\"{episode.Path}\",IsMissingEpisode={episode.IsMissingEpisode})")) - if (RemoveDuplicates(LibraryManager, Logger, episodeId, episode, series.GetPresentationUniqueKey())) - return Task.FromResult(ItemUpdateType.MetadataEdit); + if (RemoveDuplicates(_libraryManager, _logger, episodeId, episode, series.GetPresentationUniqueKey())) + itemUpdated |= ItemUpdateType.MetadataEdit; - return Task.FromResult(ItemUpdateType.None); + if (Plugin.Instance.Configuration.AutoMergeVersions && !_libraryManager.IsScanRunning && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { + await _mergeVersionsManager.SplitAndMergeEpisodesByEpisodeId(episodeId); + itemUpdated |= ItemUpdateType.MetadataEdit; + } + } + + return itemUpdated; } public static bool RemoveDuplicates(ILibraryManager libraryManager, ILogger logger, string episodeId, Episode episode, string seriesPresentationUniqueKey) diff --git a/Shokofin/Providers/CustomMovieProvider.cs b/Shokofin/Providers/CustomMovieProvider.cs new file mode 100644 index 00000000..8f02d4c5 --- /dev/null +++ b/Shokofin/Providers/CustomMovieProvider.cs @@ -0,0 +1,49 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; +using Shokofin.ExternalIds; +using Shokofin.MergeVersions; + +namespace Shokofin.Providers; + +/// +/// The custom movie provider. Responsible for de-duplicating physical movies. +/// +/// +/// This needs to be it's own class because of internal Jellyfin shenanigans +/// about how a provider cannot also be a custom provider otherwise it won't +/// save the metadata. +/// +public class CustomMovieProvider : ICustomMetadataProvider +{ + public string Name => Plugin.MetadataProviderName; + + private readonly ILogger _logger; + + private readonly ILibraryManager _libraryManager; + + private readonly MergeVersionsManager _mergeVersionsManager; + + public CustomMovieProvider(ILogger logger, ILibraryManager libraryManager, MergeVersionsManager mergeVersionsManager) + { + _logger = logger; + _libraryManager = libraryManager; + _mergeVersionsManager = mergeVersionsManager; + } + + public async Task FetchAsync(Movie movie, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + var itemUpdated = ItemUpdateType.None; + if (movie.TryGetProviderId(ShokoEpisodeId.Name, out var episodeId) && Plugin.Instance.Configuration.AutoMergeVersions && !_libraryManager.IsScanRunning && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { + await _mergeVersionsManager.SplitAndMergeMoviesByEpisodeId(episodeId); + itemUpdated |= ItemUpdateType.MetadataEdit; + } + + return itemUpdated; + } +} \ No newline at end of file diff --git a/Shokofin/Providers/CustomSeasonProvider.cs b/Shokofin/Providers/CustomSeasonProvider.cs index 70f1b021..5675aa5a 100644 --- a/Shokofin/Providers/CustomSeasonProvider.cs +++ b/Shokofin/Providers/CustomSeasonProvider.cs @@ -10,14 +10,15 @@ using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.ExternalIds; +using Shokofin.MergeVersions; using Info = Shokofin.API.Info; namespace Shokofin.Providers; /// -/// The custom season provider. Responsible for de-duplicating seasons and -/// adding/removing "missing" episodes. +/// The custom season provider. Responsible for de-duplicating seasons, +/// adding/removing "missing" episodes, and de-duplicating physical episodes. /// /// /// This needs to be it's own class because of internal Jellyfin shenanigans @@ -36,14 +37,17 @@ public class CustomSeasonProvider : ICustomMetadataProvider private readonly ILibraryManager LibraryManager; + private readonly MergeVersionsManager MergeVersionsManager; + private static bool ShouldAddMetadata => Plugin.Instance.Configuration.AddMissingMetadata; - public CustomSeasonProvider(ILogger logger, ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager) + public CustomSeasonProvider(ILogger logger, ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager, MergeVersionsManager mergeVersionsManager) { Logger = logger; ApiManager = apiManager; Lookup = lookup; LibraryManager = libraryManager; + MergeVersionsManager = mergeVersionsManager; } public async Task FetchAsync(Season season, MetadataRefreshOptions options, CancellationToken cancellationToken) @@ -84,12 +88,15 @@ public async Task FetchAsync(Season season, MetadataRefreshOptio var existingEpisodes = new HashSet(); var toRemoveEpisodes = new List(); foreach (var episode in season.Children.OfType()) { - if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) - if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Overlaps(episodeIds)) + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) { + if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Overlaps(episodeIds)) { toRemoveEpisodes.Add(episode); - else + } + else { foreach (var episodeId in episodeIds) existingEpisodes.Add(episodeId); + } + } else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Contains(episodeId)) toRemoveEpisodes.Add(episode); @@ -119,6 +126,13 @@ public async Task FetchAsync(Season season, MetadataRefreshOptio } } } + + // Merge versions. + if (Plugin.Instance.Configuration.AutoMergeVersions && !LibraryManager.IsScanRunning && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { + foreach (var episodeId in existingEpisodes) { + await MergeVersionsManager.SplitAndMergeEpisodesByEpisodeId(episodeId); + } + } } // Every other "season." else { @@ -137,12 +151,16 @@ public async Task FetchAsync(Season season, MetadataRefreshOptio var existingEpisodes = new HashSet(); var toRemoveEpisodes = new List(); foreach (var episode in season.Children.OfType()) { - if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) - if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Overlaps(episodeIds)) + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) { + if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Overlaps(episodeIds)) { toRemoveEpisodes.Add(episode); - else + } + else { foreach (var episodeId in episodeIds) existingEpisodes.Add(episodeId); + } + + } else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Contains(episodeId)) toRemoveEpisodes.Add(episode); @@ -170,6 +188,13 @@ public async Task FetchAsync(Season season, MetadataRefreshOptio itemUpdated |= ItemUpdateType.MetadataImport; } } + + // Merge versions. + if (Plugin.Instance.Configuration.AutoMergeVersions && !LibraryManager.IsScanRunning && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { + foreach (var episodeId in existingEpisodes) { + await MergeVersionsManager.SplitAndMergeEpisodesByEpisodeId(episodeId); + } + } } return itemUpdated; diff --git a/Shokofin/Providers/CustomSeriesProvider.cs b/Shokofin/Providers/CustomSeriesProvider.cs index c6e78a85..205cb572 100644 --- a/Shokofin/Providers/CustomSeriesProvider.cs +++ b/Shokofin/Providers/CustomSeriesProvider.cs @@ -10,12 +10,22 @@ using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.ExternalIds; +using Shokofin.MergeVersions; using Shokofin.Utils; using Info = Shokofin.API.Info; namespace Shokofin.Providers; +/// +/// The custom series provider. Responsible for de-duplicating seasons, +/// adding/removing "missing" episodes, and de-duplicating physical episodes. +/// +/// +/// This needs to be it's own class because of internal Jellyfin shenanigans +/// about how a provider cannot also be a custom provider otherwise it won't +/// save the metadata. +/// public class CustomSeriesProvider : ICustomMetadataProvider { public string Name => Plugin.MetadataProviderName; @@ -28,14 +38,17 @@ public class CustomSeriesProvider : ICustomMetadataProvider private readonly ILibraryManager LibraryManager; + private readonly MergeVersionsManager MergeVersionsManager; + private static bool ShouldAddMetadata => Plugin.Instance.Configuration.AddMissingMetadata; - public CustomSeriesProvider(ILogger logger, ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager) + public CustomSeriesProvider(ILogger logger, ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager, MergeVersionsManager mergeVersionsManager) { Logger = logger; ApiManager = apiManager; Lookup = lookup; LibraryManager = libraryManager; + MergeVersionsManager = mergeVersionsManager; } public async Task FetchAsync(Series series, MetadataRefreshOptions options, CancellationToken cancellationToken) @@ -144,7 +157,7 @@ public async Task FetchAsync(Series series, MetadataRefreshOptio } // Add missing episodes. - if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) + if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { foreach (var seasonInfo in showInfo.SeasonList) { foreach (var episodeId in await ApiManager.GetLocalEpisodeIdsForSeason(seasonInfo)) existingEpisodes.Add(episodeId); @@ -157,6 +170,14 @@ public async Task FetchAsync(Series series, MetadataRefreshOptio itemUpdated |= ItemUpdateType.MetadataImport; } } + } + + // Merge versions. + if (Plugin.Instance.Configuration.AutoMergeVersions && !LibraryManager.IsScanRunning && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { + foreach (var episodeId in existingEpisodes) { + await MergeVersionsManager.SplitAndMergeEpisodesByEpisodeId(episodeId); + } + } } // All other seasons. @@ -214,6 +235,13 @@ public async Task FetchAsync(Series series, MetadataRefreshOptio itemUpdated |= ItemUpdateType.MetadataImport; } } + + // Merge versions. + if (Plugin.Instance.Configuration.AutoMergeVersions && !LibraryManager.IsScanRunning && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { + foreach (var episodeId in existingEpisodes) { + await MergeVersionsManager.SplitAndMergeEpisodesByEpisodeId(episodeId); + } + } } return itemUpdated;