Browse Source

Library.db migration impovements (#13809)

* Fixes cleanup of wrong table in migration

* use dedicated context for each step

* Use prepared Context

* Fix measurement of UserData migration time

* Update logging and combine cleanup to its own stage

* fix people map not logging
migrate only readonly database

* Add id blacklisting in migration to avoid duplicated log entires
JPVenson 3 months ago
parent
commit
90a6cca92b
1 changed files with 306 additions and 197 deletions
  1. 306 197
      Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs

+ 306 - 197
Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs

@@ -73,273 +73,328 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
 
         var dataPath = _paths.DataPath;
         var libraryDbPath = Path.Combine(dataPath, DbFilename);
-        using var connection = new SqliteConnection($"Filename={libraryDbPath}");
-        var migrationTotalTime = TimeSpan.Zero;
+        using var connection = new SqliteConnection($"Filename={libraryDbPath};Mode=ReadOnly");
 
-        var stopwatch = new Stopwatch();
-        stopwatch.Start();
+        var fullOperationTimer = new Stopwatch();
+        fullOperationTimer.Start();
 
-        connection.Open();
-        using var dbContext = _provider.CreateDbContext();
-
-        migrationTotalTime += stopwatch.Elapsed;
-        _logger.LogInformation("Saving UserData entries took {0}.", stopwatch.Elapsed);
-        stopwatch.Restart();
-
-        _logger.LogInformation("Start moving TypedBaseItem.");
-        const string typedBaseItemsQuery = """
-         SELECT guid, type, data, StartDate, EndDate, ChannelId, IsMovie,
-         IsSeries, EpisodeTitle, IsRepeat, CommunityRating, CustomRating, IndexNumber, IsLocked, PreferredMetadataLanguage,
-         PreferredMetadataCountryCode, Width, Height, DateLastRefreshed, Name, Path, PremiereDate, Overview, ParentIndexNumber,
-         ProductionYear, OfficialRating, ForcedSortName, RunTimeTicks, Size, DateCreated, DateModified, Genres, ParentId, TopParentId,
-         Audio, ExternalServiceId, IsInMixedFolder, DateLastSaved, LockedFields, Studios, Tags, TrailerTypes, OriginalTitle, PrimaryVersionId,
-         DateLastMediaAdded, Album, LUFS, NormalizationGain, CriticRating, IsVirtualItem, SeriesName, UserDataKey, SeasonName, SeasonId, SeriesId,
-         PresentationUniqueKey, InheritedParentalRatingValue, ExternalSeriesId, Tagline, ProviderIds, Images, ProductionLocations, ExtraIds, TotalBitrate,
-         ExtraType, Artists, AlbumArtists, ExternalId, SeriesPresentationUniqueKey, ShowId, OwnerId, MediaType, SortName, CleanName, UnratedType FROM TypedBaseItems
-         """;
-        dbContext.BaseItems.ExecuteDelete();
+        using (var operation = GetPreparedDbContext("Cleanup database"))
+        {
+            operation.JellyfinDbContext.BaseItems.ExecuteDelete();
+            operation.JellyfinDbContext.ItemValues.ExecuteDelete();
+            operation.JellyfinDbContext.UserData.ExecuteDelete();
+            operation.JellyfinDbContext.MediaStreamInfos.ExecuteDelete();
+            operation.JellyfinDbContext.Peoples.ExecuteDelete();
+            operation.JellyfinDbContext.PeopleBaseItemMap.ExecuteDelete();
+            operation.JellyfinDbContext.Chapters.ExecuteDelete();
+            operation.JellyfinDbContext.AncestorIds.ExecuteDelete();
+        }
 
         var legacyBaseItemWithUserKeys = new Dictionary<string, BaseItemEntity>();
-        foreach (SqliteDataReader dto in connection.Query(typedBaseItemsQuery))
-        {
-            var baseItem = GetItem(dto);
-            dbContext.BaseItems.Add(baseItem.BaseItem);
-            foreach (var dataKey in baseItem.LegacyUserDataKey)
+        connection.Open();
+
+        var baseItemIds = new HashSet<Guid>();
+        using (var operation = GetPreparedDbContext("moving TypedBaseItem"))
+        {
+            const string typedBaseItemsQuery =
+            """
+            SELECT guid, type, data, StartDate, EndDate, ChannelId, IsMovie,
+            IsSeries, EpisodeTitle, IsRepeat, CommunityRating, CustomRating, IndexNumber, IsLocked, PreferredMetadataLanguage,
+            PreferredMetadataCountryCode, Width, Height, DateLastRefreshed, Name, Path, PremiereDate, Overview, ParentIndexNumber,
+            ProductionYear, OfficialRating, ForcedSortName, RunTimeTicks, Size, DateCreated, DateModified, Genres, ParentId, TopParentId,
+            Audio, ExternalServiceId, IsInMixedFolder, DateLastSaved, LockedFields, Studios, Tags, TrailerTypes, OriginalTitle, PrimaryVersionId,
+            DateLastMediaAdded, Album, LUFS, NormalizationGain, CriticRating, IsVirtualItem, SeriesName, UserDataKey, SeasonName, SeasonId, SeriesId,
+            PresentationUniqueKey, InheritedParentalRatingValue, ExternalSeriesId, Tagline, ProviderIds, Images, ProductionLocations, ExtraIds, TotalBitrate,
+            ExtraType, Artists, AlbumArtists, ExternalId, SeriesPresentationUniqueKey, ShowId, OwnerId, MediaType, SortName, CleanName, UnratedType FROM TypedBaseItems
+            """;
+            using (new TrackedMigrationStep("Loading TypedBaseItems", _logger))
+            {
+                foreach (SqliteDataReader dto in connection.Query(typedBaseItemsQuery))
+                {
+                    var baseItem = GetItem(dto);
+                    operation.JellyfinDbContext.BaseItems.Add(baseItem.BaseItem);
+                    baseItemIds.Add(baseItem.BaseItem.Id);
+                    foreach (var dataKey in baseItem.LegacyUserDataKey)
+                    {
+                        legacyBaseItemWithUserKeys[dataKey] = baseItem.BaseItem;
+                    }
+                }
+            }
+
+            using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.BaseItems.Local.Count} BaseItem entries", _logger))
             {
-                legacyBaseItemWithUserKeys[dataKey] = baseItem.BaseItem;
+                operation.JellyfinDbContext.SaveChanges();
             }
         }
 
-        _logger.LogInformation("Try saving {0} BaseItem entries.", dbContext.BaseItems.Local.Count);
-        dbContext.SaveChanges();
-        migrationTotalTime += stopwatch.Elapsed;
-        _logger.LogInformation("Saving BaseItems entries took {0}.", stopwatch.Elapsed);
-        stopwatch.Restart();
+        using (var operation = GetPreparedDbContext("moving ItemValues"))
+        {
+            // do not migrate inherited types as they are now properly mapped in search and lookup.
+            const string itemValueQuery =
+            """
+            SELECT ItemId, Type, Value, CleanValue FROM ItemValues
+                        WHERE Type <> 6 AND EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = ItemValues.ItemId)
+            """;
 
-        _logger.LogInformation("Start moving ItemValues.");
-        // do not migrate inherited types as they are now properly mapped in search and lookup.
-        const string itemValueQuery =
-        """
-        SELECT ItemId, Type, Value, CleanValue FROM ItemValues
-                    WHERE Type <> 6 AND EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = ItemValues.ItemId)
-        """;
-        dbContext.ItemValues.ExecuteDelete();
+            // EFCores local lookup sucks. We cannot use context.ItemValues.Local here because its just super slow.
+            var localItems = new Dictionary<(int Type, string CleanValue), (Database.Implementations.Entities.ItemValue ItemValue, List<Guid> ItemIds)>();
+            using (new TrackedMigrationStep("loading ItemValues", _logger))
+            {
+                foreach (SqliteDataReader dto in connection.Query(itemValueQuery))
+                {
+                    var itemId = dto.GetGuid(0);
+                    var entity = GetItemValue(dto);
+                    var key = ((int)entity.Type, entity.CleanValue);
+                    if (!localItems.TryGetValue(key, out var existing))
+                    {
+                        localItems[key] = existing = (entity, []);
+                    }
 
-        // EFCores local lookup sucks. We cannot use context.ItemValues.Local here because its just super slow.
-        var localItems = new Dictionary<(int Type, string CleanValue), (Database.Implementations.Entities.ItemValue ItemValue, List<Guid> ItemIds)>();
+                    existing.ItemIds.Add(itemId);
+                }
 
-        foreach (SqliteDataReader dto in connection.Query(itemValueQuery))
-        {
-            var itemId = dto.GetGuid(0);
-            var entity = GetItemValue(dto);
-            var key = ((int)entity.Type, entity.CleanValue);
-            if (!localItems.TryGetValue(key, out var existing))
-            {
-                localItems[key] = existing = (entity, []);
+                foreach (var item in localItems)
+                {
+                    operation.JellyfinDbContext.ItemValues.Add(item.Value.ItemValue);
+                    operation.JellyfinDbContext.ItemValuesMap.AddRange(item.Value.ItemIds.Distinct().Select(f => new ItemValueMap()
+                    {
+                        Item = null!,
+                        ItemValue = null!,
+                        ItemId = f,
+                        ItemValueId = item.Value.ItemValue.ItemValueId
+                    }));
+                }
             }
 
-            existing.ItemIds.Add(itemId);
+            using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.ItemValues.Local.Count} ItemValues entries", _logger))
+            {
+                operation.JellyfinDbContext.SaveChanges();
+            }
         }
 
-        foreach (var item in localItems)
+        using (var operation = GetPreparedDbContext("moving UserData"))
         {
-            dbContext.ItemValues.Add(item.Value.ItemValue);
-            dbContext.ItemValuesMap.AddRange(item.Value.ItemIds.Distinct().Select(f => new ItemValueMap()
+            var queryResult = connection.Query(
+            """
+            SELECT key, userId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStreamIndex, SubtitleStreamIndex FROM UserDatas
+
+            WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.UserDataKey = UserDatas.key)
+            """);
+
+            using (new TrackedMigrationStep("loading UserData", _logger))
             {
-                Item = null!,
-                ItemValue = null!,
-                ItemId = f,
-                ItemValueId = item.Value.ItemValue.ItemValueId
-            }));
-        }
+                var users = operation.JellyfinDbContext.Users.AsNoTracking().ToImmutableArray();
+                var userIdBlacklist = new HashSet<int>();
 
-        _logger.LogInformation("Try saving {0} ItemValues entries.", dbContext.ItemValues.Local.Count);
-        dbContext.SaveChanges();
-        migrationTotalTime += stopwatch.Elapsed;
-        _logger.LogInformation("Saving People ItemValues took {0}.", stopwatch.Elapsed);
-        stopwatch.Restart();
+                foreach (var entity in queryResult)
+                {
+                    var userData = GetUserData(users, entity, userIdBlacklist);
+                    if (userData is null)
+                    {
+                        var userDataId = entity.GetString(0);
+                        var internalUserId = entity.GetInt32(1);
 
-        _logger.LogInformation("Start moving UserData.");
-        var queryResult = connection.Query("""
-        SELECT key, userId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStreamIndex, SubtitleStreamIndex FROM UserDatas
+                        if (!userIdBlacklist.Contains(internalUserId))
+                        {
+                            _logger.LogError("Was not able to migrate user data with key {0} because its id {InternalId} does not match any existing user.", userDataId, internalUserId);
+                            userIdBlacklist.Add(internalUserId);
+                        }
 
-        WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.UserDataKey = UserDatas.key)
-        """);
+                        continue;
+                    }
 
-        dbContext.UserData.ExecuteDelete();
+                    if (!legacyBaseItemWithUserKeys.TryGetValue(userData.CustomDataKey!, out var refItem))
+                    {
+                        _logger.LogError("Was not able to migrate user data with key {0} because it does not reference a valid BaseItem.", entity.GetString(0));
+                        continue;
+                    }
 
-        var users = dbContext.Users.AsNoTracking().ToImmutableArray();
+                    userData.ItemId = refItem.Id;
+                    operation.JellyfinDbContext.UserData.Add(userData);
+                }
 
-        foreach (var entity in queryResult)
-        {
-            var userData = GetUserData(users, entity);
-            if (userData is null)
-            {
-                _logger.LogError("Was not able to migrate user data with key {0}", entity.GetString(0));
-                continue;
+                users.Clear();
             }
 
-            if (!legacyBaseItemWithUserKeys.TryGetValue(userData.CustomDataKey!, out var refItem))
+            legacyBaseItemWithUserKeys.Clear();
+
+            using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.UserData.Local.Count} UserData entries", _logger))
             {
-                _logger.LogError("Was not able to migrate user data with key {0} because it does not reference a valid BaseItem.", entity.GetString(0));
-                continue;
+                operation.JellyfinDbContext.SaveChanges();
             }
-
-            userData.ItemId = refItem.Id;
-            dbContext.UserData.Add(userData);
         }
 
-        users.Clear();
-        legacyBaseItemWithUserKeys.Clear();
-        _logger.LogInformation("Try saving {0} UserData entries.", dbContext.UserData.Local.Count);
-        dbContext.SaveChanges();
-
-        _logger.LogInformation("Start moving MediaStreamInfos.");
-        const string mediaStreamQuery = """
-        SELECT ItemId, StreamIndex, StreamType, Codec, Language, ChannelLayout, Profile, AspectRatio, Path,
-        IsInterlaced, BitRate, Channels, SampleRate, IsDefault, IsForced, IsExternal, Height, Width,
-        AverageFrameRate, RealFrameRate, Level, PixelFormat, BitDepth, IsAnamorphic, RefFrames, CodecTag,
-        Comment, NalLengthSize, IsAvc, Title, TimeBase, CodecTimeBase, ColorPrimaries, ColorSpace, ColorTransfer,
-        DvVersionMajor, DvVersionMinor, DvProfile, DvLevel, RpuPresentFlag, ElPresentFlag, BlPresentFlag, DvBlSignalCompatibilityId, IsHearingImpaired
-        FROM MediaStreams
-        WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = MediaStreams.ItemId)
-        """;
-        dbContext.MediaStreamInfos.ExecuteDelete();
-
-        foreach (SqliteDataReader dto in connection.Query(mediaStreamQuery))
+        using (var operation = GetPreparedDbContext("moving MediaStreamInfos"))
         {
-            dbContext.MediaStreamInfos.Add(GetMediaStream(dto));
-        }
+            const string mediaStreamQuery =
+            """
+            SELECT ItemId, StreamIndex, StreamType, Codec, Language, ChannelLayout, Profile, AspectRatio, Path,
+            IsInterlaced, BitRate, Channels, SampleRate, IsDefault, IsForced, IsExternal, Height, Width,
+            AverageFrameRate, RealFrameRate, Level, PixelFormat, BitDepth, IsAnamorphic, RefFrames, CodecTag,
+            Comment, NalLengthSize, IsAvc, Title, TimeBase, CodecTimeBase, ColorPrimaries, ColorSpace, ColorTransfer,
+            DvVersionMajor, DvVersionMinor, DvProfile, DvLevel, RpuPresentFlag, ElPresentFlag, BlPresentFlag, DvBlSignalCompatibilityId, IsHearingImpaired
+            FROM MediaStreams
+            WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = MediaStreams.ItemId)
+            """;
 
-        _logger.LogInformation("Try saving {0} MediaStreamInfos entries.", dbContext.MediaStreamInfos.Local.Count);
-        dbContext.SaveChanges();
-
-        migrationTotalTime += stopwatch.Elapsed;
-        _logger.LogInformation("Saving MediaStreamInfos entries took {0}.", stopwatch.Elapsed);
-        stopwatch.Restart();
-
-        _logger.LogInformation("Start moving People.");
-        const string personsQuery = """
-        SELECT ItemId, Name, Role, PersonType, SortOrder FROM People
-        WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = People.ItemId)
-        """;
-        dbContext.Peoples.ExecuteDelete();
-        dbContext.PeopleBaseItemMap.ExecuteDelete();
-
-        var peopleCache = new Dictionary<string, (People Person, List<PeopleBaseItemMap> Items)>();
-        var baseItemIds = dbContext.BaseItems.Select(b => b.Id).ToHashSet();
-
-        foreach (SqliteDataReader reader in connection.Query(personsQuery))
-        {
-            var itemId = reader.GetGuid(0);
-            if (!baseItemIds.Contains(itemId))
+            using (new TrackedMigrationStep("loading MediaStreamInfos", _logger))
             {
-                _logger.LogError("Dont save person {0} because its not in use by any BaseItem", reader.GetString(1));
-                continue;
+                foreach (SqliteDataReader dto in connection.Query(mediaStreamQuery))
+                {
+                    operation.JellyfinDbContext.MediaStreamInfos.Add(GetMediaStream(dto));
+                }
             }
 
-            var entity = GetPerson(reader);
-            if (!peopleCache.TryGetValue(entity.Name, out var personCache))
+            using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.MediaStreamInfos.Local.Count} MediaStreamInfos entries", _logger))
             {
-                peopleCache[entity.Name] = personCache = (entity, []);
+                operation.JellyfinDbContext.SaveChanges();
             }
+        }
 
-            if (reader.TryGetString(2, out var role))
-            {
-            }
+        using (var operation = GetPreparedDbContext("moving People"))
+        {
+            const string personsQuery =
+            """
+            SELECT ItemId, Name, Role, PersonType, SortOrder FROM People
+            WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = People.ItemId)
+            """;
 
-            int? sortOrder = reader.IsDBNull(4) ? null : reader.GetInt32(4);
+            var peopleCache = new Dictionary<string, (People Person, List<PeopleBaseItemMap> Items)>();
 
-            personCache.Items.Add(new PeopleBaseItemMap()
+            using (new TrackedMigrationStep("loading People", _logger))
             {
-                Item = null!,
-                ItemId = itemId,
-                People = null!,
-                PeopleId = personCache.Person.Id,
-                ListOrder = sortOrder,
-                SortOrder = sortOrder,
-                Role = role
-            });
-        }
+                foreach (SqliteDataReader reader in connection.Query(personsQuery))
+                {
+                    var itemId = reader.GetGuid(0);
+                    if (!baseItemIds.Contains(itemId))
+                    {
+                        _logger.LogError("Dont save person {0} because its not in use by any BaseItem", reader.GetString(1));
+                        continue;
+                    }
 
-        baseItemIds.Clear();
+                    var entity = GetPerson(reader);
+                    if (!peopleCache.TryGetValue(entity.Name, out var personCache))
+                    {
+                        peopleCache[entity.Name] = personCache = (entity, []);
+                    }
 
-        foreach (var item in peopleCache)
-        {
-            dbContext.Peoples.Add(item.Value.Person);
-            dbContext.PeopleBaseItemMap.AddRange(item.Value.Items.DistinctBy(e => (e.ItemId, e.PeopleId)));
-        }
+                    if (reader.TryGetString(2, out var role))
+                    {
+                    }
 
-        peopleCache.Clear();
+                    int? sortOrder = reader.IsDBNull(4) ? null : reader.GetInt32(4);
 
-        _logger.LogInformation("Try saving {0} People entries.", dbContext.Peoples.Local.Count);
-        dbContext.SaveChanges();
-        migrationTotalTime += stopwatch.Elapsed;
-        _logger.LogInformation("Saving People entries took {0}.", stopwatch.Elapsed);
-        stopwatch.Restart();
+                    personCache.Items.Add(new PeopleBaseItemMap()
+                    {
+                        Item = null!,
+                        ItemId = itemId,
+                        People = null!,
+                        PeopleId = personCache.Person.Id,
+                        ListOrder = sortOrder,
+                        SortOrder = sortOrder,
+                        Role = role
+                    });
+                }
 
-        _logger.LogInformation("Start moving Chapters.");
-        const string chapterQuery = """
-        SELECT ItemId,StartPositionTicks,Name,ImagePath,ImageDateModified,ChapterIndex from Chapters2
-        WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = Chapters2.ItemId)
-        """;
-        dbContext.Chapters.ExecuteDelete();
+                baseItemIds.Clear();
 
-        foreach (SqliteDataReader dto in connection.Query(chapterQuery))
-        {
-            var chapter = GetChapter(dto);
-            dbContext.Chapters.Add(chapter);
-        }
+                foreach (var item in peopleCache)
+                {
+                    operation.JellyfinDbContext.Peoples.Add(item.Value.Person);
+                    operation.JellyfinDbContext.PeopleBaseItemMap.AddRange(item.Value.Items.DistinctBy(e => (e.ItemId, e.PeopleId)));
+                }
 
-        _logger.LogInformation("Try saving {0} Chapters entries.", dbContext.Chapters.Local.Count);
-        dbContext.SaveChanges();
-        migrationTotalTime += stopwatch.Elapsed;
-        _logger.LogInformation("Saving Chapters took {0}.", stopwatch.Elapsed);
-        stopwatch.Restart();
+                peopleCache.Clear();
+            }
 
-        _logger.LogInformation("Start moving AncestorIds.");
-        const string ancestorIdsQuery = """
-        SELECT ItemId, AncestorId, AncestorIdText FROM AncestorIds
-        WHERE
-        EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = AncestorIds.ItemId)
-        AND
-        EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = AncestorIds.AncestorId)
-        """;
-        dbContext.AncestorIds.ExecuteDelete();
+            using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.Peoples.Local.Count} People entries and {operation.JellyfinDbContext.PeopleBaseItemMap.Local.Count} maps", _logger))
+            {
+                operation.JellyfinDbContext.SaveChanges();
+            }
+        }
 
-        foreach (SqliteDataReader dto in connection.Query(ancestorIdsQuery))
+        using (var operation = GetPreparedDbContext("moving Chapters"))
         {
-            var ancestorId = GetAncestorId(dto);
-            dbContext.AncestorIds.Add(ancestorId);
+            const string chapterQuery =
+            """
+            SELECT ItemId,StartPositionTicks,Name,ImagePath,ImageDateModified,ChapterIndex from Chapters2
+            WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = Chapters2.ItemId)
+            """;
+
+            using (new TrackedMigrationStep("loading Chapters", _logger))
+            {
+                foreach (SqliteDataReader dto in connection.Query(chapterQuery))
+                {
+                    var chapter = GetChapter(dto);
+                    operation.JellyfinDbContext.Chapters.Add(chapter);
+                }
+            }
+
+            using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.Chapters.Local.Count} Chapters entries", _logger))
+            {
+                operation.JellyfinDbContext.SaveChanges();
+            }
         }
 
-        _logger.LogInformation("Try saving {0} AncestorIds entries.", dbContext.AncestorIds.Local.Count);
+        using (var operation = GetPreparedDbContext("moving AncestorIds"))
+        {
+            const string ancestorIdsQuery =
+            """
+            SELECT ItemId, AncestorId, AncestorIdText FROM AncestorIds
+            WHERE
+            EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = AncestorIds.ItemId)
+            AND
+            EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = AncestorIds.AncestorId)
+            """;
 
-        dbContext.SaveChanges();
-        migrationTotalTime += stopwatch.Elapsed;
-        _logger.LogInformation("Saving AncestorIds took {0}.", stopwatch.Elapsed);
-        stopwatch.Restart();
+            using (new TrackedMigrationStep("loading AncestorIds", _logger))
+            {
+                foreach (SqliteDataReader dto in connection.Query(ancestorIdsQuery))
+                {
+                    var ancestorId = GetAncestorId(dto);
+                    operation.JellyfinDbContext.AncestorIds.Add(ancestorId);
+                }
+            }
+
+            using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.AncestorIds.Local.Count} AncestorId entries", _logger))
+            {
+                operation.JellyfinDbContext.SaveChanges();
+            }
+        }
 
         connection.Close();
+
         _logger.LogInformation("Migration of the Library.db done.");
-        _logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old");
+        _logger.LogInformation("Migrating Library db took {0}.", fullOperationTimer.Elapsed);
 
         SqliteConnection.ClearAllPools();
 
+        _logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old");
         File.Move(libraryDbPath, libraryDbPath + ".old", true);
 
-        _logger.LogInformation("Migrating Library db took {0}.", migrationTotalTime);
-
         _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false).GetAwaiter().GetResult();
     }
 
-    private UserData? GetUserData(ImmutableArray<User> users, SqliteDataReader dto)
+    private DatabaseMigrationStep GetPreparedDbContext(string operationName)
+    {
+        var dbContext = _provider.CreateDbContext();
+        dbContext.ChangeTracker.AutoDetectChangesEnabled = false;
+        dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
+        return new DatabaseMigrationStep(dbContext, operationName, _logger);
+    }
+
+    private UserData? GetUserData(ImmutableArray<User> users, SqliteDataReader dto, HashSet<int> userIdBlacklist)
     {
         var internalUserId = dto.GetInt32(1);
         var user = users.FirstOrDefault(e => e.InternalId == internalUserId);
 
         if (user is null)
         {
+            if (userIdBlacklist.Contains(internalUserId))
+            {
+                return null;
+            }
+
             _logger.LogError("Tried to find user with index '{Idx}' but there are only '{MaxIdx}' users.", internalUserId, users.Length);
             return null;
         }
@@ -1214,4 +1269,58 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
 
         return image;
     }
+
+    private class TrackedMigrationStep : IDisposable
+    {
+        private readonly string _operationName;
+        private readonly ILogger _logger;
+        private readonly Stopwatch _operationTimer;
+        private bool _disposed;
+
+        public TrackedMigrationStep(string operationName, ILogger logger)
+        {
+            _operationName = operationName;
+            _logger = logger;
+            _operationTimer = Stopwatch.StartNew();
+            logger.LogInformation("Start {OperationName}", operationName);
+        }
+
+        public bool Disposed
+        {
+            get => _disposed;
+            set => _disposed = value;
+        }
+
+        public virtual void Dispose()
+        {
+            if (Disposed)
+            {
+                return;
+            }
+
+            Disposed = true;
+            _logger.LogInformation("{OperationName} took '{Time}'", _operationName, _operationTimer.Elapsed);
+        }
+    }
+
+    private sealed class DatabaseMigrationStep : TrackedMigrationStep
+    {
+        public DatabaseMigrationStep(JellyfinDbContext jellyfinDbContext, string operationName, ILogger logger) : base(operationName, logger)
+        {
+            JellyfinDbContext = jellyfinDbContext;
+        }
+
+        public JellyfinDbContext JellyfinDbContext { get; }
+
+        public override void Dispose()
+        {
+            if (Disposed)
+            {
+                return;
+            }
+
+            JellyfinDbContext.Dispose();
+            base.Dispose();
+        }
+    }
 }