From 5882006ee7657a19e802c4990be6849b847d71a3 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 7 Mar 2026 21:23:16 +0100 Subject: [PATCH] Optimize migrations --- .../IO/ManagedFileSystem.cs | 6 + .../Routines/CleanupOrphanedExtras.cs | 36 ++--- .../FixIncorrectOwnerIdRelationships.cs | 139 ++++++++---------- .../Routines/MigrateLinkedChildren.cs | 60 +++----- .../Migrations/Routines/RefreshCleanNames.cs | 5 +- 5 files changed, 109 insertions(+), 137 deletions(-) diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index 4d68cb4444..199407044b 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -586,6 +586,12 @@ namespace Emby.Server.Implementations.IO /// public virtual IEnumerable GetFiles(string path, string searchPattern, IReadOnlyList? extensions, bool enableCaseSensitiveExtensions, bool recursive = false) { + if (!Directory.Exists(path)) + { + _logger.LogWarning("Directory does not exist: {Path}", path); + return []; + } + var enumerationOptions = GetEnumerationOptions(recursive); // On linux and macOS the search pattern is case-sensitive diff --git a/Jellyfin.Server/Migrations/Routines/CleanupOrphanedExtras.cs b/Jellyfin.Server/Migrations/Routines/CleanupOrphanedExtras.cs index fccecfa9dc..14abaa7317 100644 --- a/Jellyfin.Server/Migrations/Routines/CleanupOrphanedExtras.cs +++ b/Jellyfin.Server/Migrations/Routines/CleanupOrphanedExtras.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -12,6 +13,7 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.IO; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -42,6 +44,7 @@ public class CleanupOrphanedExtras : IAsyncMigrationRoutine /// The media source manager. /// The media segments manager. /// The configuration manager. + /// The file system. public CleanupOrphanedExtras( IStartupLogger logger, IDbContextFactory dbContextFactory, @@ -52,7 +55,8 @@ public class CleanupOrphanedExtras : IAsyncMigrationRoutine IRecordingsManager recordingsManager, IMediaSourceManager mediaSourceManager, IMediaSegmentManager mediaSegmentManager, - IServerConfigurationManager configurationManager) + IServerConfigurationManager configurationManager, + IFileSystem fileSystem) { _logger = logger; _dbContextFactory = dbContextFactory; @@ -64,6 +68,7 @@ public class CleanupOrphanedExtras : IAsyncMigrationRoutine BaseItem.MediaSourceManager ??= mediaSourceManager; BaseItem.MediaSegmentManager ??= mediaSegmentManager; BaseItem.ConfigurationManager ??= configurationManager; + BaseItem.FileSystem ??= fileSystem; Video.RecordingsManager ??= recordingsManager; } @@ -88,35 +93,20 @@ public class CleanupOrphanedExtras : IAsyncMigrationRoutine _logger.LogInformation("Found {Count} orphaned extras to remove", orphanedItemIds.Count); - var deleteOptions = new DeleteOptions - { - DeleteFileLocation = false // Extras don't have their own media files - }; - - var deletedCount = 0; + // Batch-resolve items for metadata path cleanup, then delete all at once + var itemsToDelete = new List(); foreach (var itemId in orphanedItemIds) { - cancellationToken.ThrowIfCancellationRequested(); - var item = _libraryManager.GetItemById(itemId); - if (item is null) + if (item is not null) { - _logger.LogDebug("Item {ItemId} not found in library, may have been already deleted", itemId); - continue; - } - - try - { - _libraryManager.DeleteItem(item, deleteOptions, notifyParentItem: false); - deletedCount++; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to delete orphaned item {ItemId} ({ItemName})", item.Id, item.Name); + itemsToDelete.Add(item); } } - _logger.LogInformation("Successfully removed {Count} orphaned extras", deletedCount); + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete); + + _logger.LogInformation("Successfully removed {Count} orphaned extras", itemsToDelete.Count); } } } diff --git a/Jellyfin.Server/Migrations/Routines/FixIncorrectOwnerIdRelationships.cs b/Jellyfin.Server/Migrations/Routines/FixIncorrectOwnerIdRelationships.cs index c926362802..8538f5d7dc 100644 --- a/Jellyfin.Server/Migrations/Routines/FixIncorrectOwnerIdRelationships.cs +++ b/Jellyfin.Server/Migrations/Routines/FixIncorrectOwnerIdRelationships.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -84,12 +85,8 @@ public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine _logger.LogInformation("Found {Count} paths with duplicate database entries", duplicatePaths.Count); - var deleteOptions = new DeleteOptions - { - DeleteFileLocation = false // Don't delete the actual file, just the database entry - }; - - var deletedCount = 0; + // Collect all duplicate IDs to delete in one batch + var allIdsToDelete = new List(); foreach (var path in duplicatePaths) { cancellationToken.ThrowIfCancellationRequested(); @@ -125,52 +122,28 @@ public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine continue; } - // Delete all other items with this path - var itemsToDelete = itemsWithPath.Where(i => !i.Id.Equals(itemToKeep.Id)).Select(i => i.Id).ToList(); - foreach (var itemId in itemsToDelete) - { - var item = _libraryManager.GetItemById(itemId); - if (item is not null) - { - var deleted = false; - try - { - _libraryManager.DeleteItem(item, deleteOptions, notifyParentItem: false); - deletedCount++; - deleted = true; - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Failed to delete duplicate item {ItemId} at path {Path}", itemId, path); - } - catch (UnauthorizedAccessException ex) - { - _logger.LogWarning(ex, "Failed to delete duplicate item {ItemId} at path {Path}", itemId, path); - } - catch (NullReferenceException ex) - { - _logger.LogWarning(ex, "Failed to delete duplicate item {ItemId} at path {Path} via LibraryManager - falling back to direct database deletion", itemId, path); - } + allIdsToDelete.AddRange(itemsWithPath.Where(i => !i.Id.Equals(itemToKeep.Id)).Select(i => i.Id)); + } - // If LibraryManager.DeleteItem failed, delete directly from database - if (!deleted) - { - try - { - _persistenceService.DeleteItem([itemId]); - deletedCount++; - _logger.LogInformation("Successfully deleted duplicate item {ItemId} at path {Path} via direct database deletion", itemId, path); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to delete duplicate item {ItemId} at path {Path} via direct database deletion", itemId, path); - } - } - } + if (allIdsToDelete.Count > 0) + { + // Batch-resolve items for metadata path cleanup, then delete all at once + var itemsToDelete = allIdsToDelete + .Select(id => _libraryManager.GetItemById(id)) + .Where(item => item is not null) + .ToList(); + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + + // Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager + var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); + var unresolvedIds = allIdsToDelete.Where(id => !deletedIds.Contains(id)).ToList(); + if (unresolvedIds.Count > 0) + { + _persistenceService.DeleteItem(unresolvedIds); } } - _logger.LogInformation("Successfully removed {Count} duplicate database entries", deletedCount); + _logger.LogInformation("Successfully removed {Count} duplicate database entries", allIdsToDelete.Count); } private async Task ClearIncorrectOwnerIdsAsync(JellyfinDbContext context, CancellationToken cancellationToken) @@ -236,39 +209,56 @@ public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine _logger.LogInformation("Found {Count} orphaned extras to reassign", orphanedExtras.Count); + // Build a lookup of directory -> first video/movie item for parent resolution + var extraDirectories = orphanedExtras + .Where(e => !string.IsNullOrEmpty(e.Path)) + .Select(e => System.IO.Path.GetDirectoryName(e.Path)) + .Where(d => !string.IsNullOrEmpty(d)) + .Distinct() + .ToList(); + + // Load all potential parent video/movies with paths in one query + var videoTypes = new[] + { + "MediaBrowser.Controller.Entities.Video", + "MediaBrowser.Controller.Entities.Movies.Movie" + }; + var potentialParents = await context.BaseItems + .Where(b => b.Path != null && videoTypes.Contains(b.Type)) + .Select(b => new { b.Id, b.Path }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + // Build directory -> parent ID mapping + var dirToParent = new Dictionary(); + foreach (var dir in extraDirectories) + { + var parent = potentialParents + .Where(p => p.Path!.StartsWith(dir!, StringComparison.OrdinalIgnoreCase)) + .OrderBy(p => p.Id) + .FirstOrDefault(); + if (parent is not null) + { + dirToParent[dir!] = parent.Id; + } + } + var reassignedCount = 0; foreach (var extra in orphanedExtras) { - cancellationToken.ThrowIfCancellationRequested(); - - // Find the parent path from the extra's path (extras are usually in same directory as parent) if (string.IsNullOrEmpty(extra.Path)) { continue; } var extraDirectory = System.IO.Path.GetDirectoryName(extra.Path); - if (string.IsNullOrEmpty(extraDirectory)) + if (!string.IsNullOrEmpty(extraDirectory) && dirToParent.TryGetValue(extraDirectory, out var parentId)) { - continue; - } - - // Find potential parent in same directory - var potentialParent = await context.BaseItems - .Where(b => b.Path != null && b.Path.StartsWith(extraDirectory)) - .Where(b => b.Type == "MediaBrowser.Controller.Entities.Video" || b.Type == "MediaBrowser.Controller.Entities.Movies.Movie") - .OrderBy(b => b.Id) - .FirstOrDefaultAsync(cancellationToken) - .ConfigureAwait(false); - - if (potentialParent is not null) - { - extra.OwnerId = potentialParent.Id; + extra.OwnerId = parentId; reassignedCount++; } else { - // Can't find a parent, clear the OwnerId extra.OwnerId = null; } } @@ -301,16 +291,17 @@ public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine _logger.LogInformation("Found {Count} alternate version items that need PrimaryVersionId populated", alternateVersionLinks.Count); + // Batch-load all child items in a single query + var childIds = alternateVersionLinks.Select(l => l.ChildId).Distinct().ToList(); + var childItems = await context.BaseItems + .Where(b => childIds.Contains(b.Id)) + .ToDictionaryAsync(b => b.Id, cancellationToken) + .ConfigureAwait(false); + var updatedCount = 0; foreach (var link in alternateVersionLinks) { - cancellationToken.ThrowIfCancellationRequested(); - - var childItem = await context.BaseItems - .FirstOrDefaultAsync(b => b.Id.Equals(link.ChildId), cancellationToken) - .ConfigureAwait(false); - - if (childItem is not null) + if (childItems.TryGetValue(link.ChildId, out var childItem)) { childItem.PrimaryVersionId = link.ParentId; updatedCount++; diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs index 2048ad1b57..f0e4803f18 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs @@ -272,16 +272,13 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine _logger.LogInformation("Found {Count} wrong-type alternate version items to remove.", wrongTypeChildIds.Count); - foreach (var childId in wrongTypeChildIds) - { - var item = _libraryManager.GetItemById(childId); - if (item is not null) - { - _libraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false }); - } - } + var itemsToDelete = wrongTypeChildIds + .Select(id => _libraryManager.GetItemById(id)) + .Where(item => item is not null) + .ToList(); + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); - _logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", wrongTypeChildIds.Count); + _logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", itemsToDelete.Count); } private void CleanupOrphanedAlternateVersionBaseItems(JellyfinDbContext context) @@ -306,16 +303,13 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine _logger.LogInformation("Found {Count} orphaned alternate version BaseItems to remove.", orphanedVersionIds.Count); - foreach (var id in orphanedVersionIds) - { - var item = _libraryManager.GetItemById(id); - if (item is not null) - { - _libraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false }); - } - } + var itemsToDelete = orphanedVersionIds + .Select(id => _libraryManager.GetItemById(id)) + .Where(item => item is not null) + .ToList(); + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); - _logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", orphanedVersionIds.Count); + _logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", itemsToDelete.Count); } private void CleanupItemsFromDeletedLibraries(JellyfinDbContext context) @@ -338,16 +332,13 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine _logger.LogInformation("Found {Count} items from deleted libraries to remove.", orphanedIds.Count); - foreach (var id in orphanedIds) - { - var item = _libraryManager.GetItemById(id); - if (item is not null) - { - _libraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false }); - } - } + var itemsToDelete = orphanedIds + .Select(id => _libraryManager.GetItemById(id)) + .Where(item => item is not null) + .ToList(); + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); - _logger.LogInformation("Removed {Count} items from deleted libraries.", orphanedIds.Count); + _logger.LogInformation("Removed {Count} items from deleted libraries.", itemsToDelete.Count); } private void CleanupStaleFileEntries(JellyfinDbContext context) @@ -429,16 +420,13 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine _logger.LogInformation("Found {Count} stale items to remove.", staleIds.Count); - foreach (var id in staleIds) - { - var item = _libraryManager.GetItemById(id); - if (item is not null) - { - _libraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false }); - } - } + var itemsToDelete = staleIds + .Select(id => _libraryManager.GetItemById(id)) + .Where(item => item is not null) + .ToList(); + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); - _logger.LogInformation("Removed {Count} stale items.", staleIds.Count); + _logger.LogInformation("Removed {Count} stale items.", itemsToDelete.Count); } private void CleanupOrphanedLinkedChildren(JellyfinDbContext context) diff --git a/Jellyfin.Server/Migrations/Routines/RefreshCleanNames.cs b/Jellyfin.Server/Migrations/Routines/RefreshCleanNames.cs index 4b5659cd6c..eca50ac100 100644 --- a/Jellyfin.Server/Migrations/Routines/RefreshCleanNames.cs +++ b/Jellyfin.Server/Migrations/Routines/RefreshCleanNames.cs @@ -1,13 +1,10 @@ using System; using System.Diagnostics; using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations; -using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; -using Jellyfin.Server.Implementations.Item; using Jellyfin.Server.ServerSetupApp; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -40,7 +37,7 @@ public class RefreshCleanNames : IAsyncMigrationRoutine /// public async Task PerformAsync(CancellationToken cancellationToken) { - const int Limit = 1000; + const int Limit = 10000; int itemCount = 0; var sw = Stopwatch.StartNew();