diff options
Diffstat (limited to 'Jellyfin.Server/Migrations')
42 files changed, 2005 insertions, 254 deletions
diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index d664b718bc..beafc3916f 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -193,26 +193,50 @@ internal class JellyfinMigrationService { var historyRepository = dbContext.GetService<IHistoryRepository>(); var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>(); - var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false); - var pendingCodeMigrations = migrationStage - .Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId())) - .Select(e => (Key: e.BuildCodeMigrationId(), Migration: new InternalCodeMigration(e, serviceProvider, dbContext))) - .ToArray(); - - (string Key, InternalDatabaseMigration Migration)[] pendingDatabaseMigrations = []; - if (stage is JellyfinMigrationStageTypes.CoreInitialisation) + var completedMigrations = 0; + string? lastMigrationKey = null; + + while (true) { - pendingDatabaseMigrations = migrationsAssembly.Migrations.Where(f => appliedMigrations.All(e => e.MigrationId != f.Key)) - .Select(e => (Key: e.Key, Migration: new InternalDatabaseMigration(e, dbContext))) - .ToArray(); - } + // A single migration can change which migrations still apply: IMigrator.MigrateAsync treats its argument as the + // state to end up in, so it reverts everything applied after it, and a reverted migration can take code migrations + // with it (AddNormalizedUsername.Down drops the UpdateNormalizedUsername history row). Anything computed before + // that point is stale, so only ever run the next migration and then work out the pending set again. + var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false); + var pendingCodeMigrations = migrationStage + .Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId())) + .Select(e => (Key: e.BuildCodeMigrationId(), Migration: new InternalCodeMigration(e, serviceProvider, dbContext))) + .ToArray(); - (string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations]; - logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage); - var migrations = pendingMigrations.OrderBy(e => e.Key).ToArray(); + (string Key, InternalDatabaseMigration Migration)[] pendingDatabaseMigrations = []; + if (stage is JellyfinMigrationStageTypes.CoreInitialisation) + { + pendingDatabaseMigrations = migrationsAssembly.Migrations.Where(f => appliedMigrations.All(e => e.MigrationId != f.Key)) + .Select(e => (Key: e.Key, Migration: new InternalDatabaseMigration(e, dbContext))) + .ToArray(); + } - foreach (var item in migrations) - { + (string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations]; + if (pendingMigrations.Length == 0) + { + break; + } + + if (completedMigrations == 0) + { + logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingMigrations.Length, stage); + } + + var item = pendingMigrations.OrderBy(e => e.Key, StringComparer.Ordinal).First(); + if (string.Equals(item.Key, lastMigrationKey, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Migration {item.Key} ran but did not record itself as applied and would repeat indefinitely."); + } + + lastMigrationKey = item.Key; + + // Surface generic "Running migration X of Y" progress in the always-visible startup UI header. + SetupServer.ReportActivity(StartupActivity.Migration(completedMigrations + 1, completedMigrations + pendingMigrations.Length)); var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}"); try { @@ -270,6 +294,8 @@ internal class JellyfinMigrationService throw; } + + completedMigrations++; } } } diff --git a/Jellyfin.Server/Migrations/MigrationOptions.cs b/Jellyfin.Server/Migrations/MigrationOptions.cs index c9710f1fd1..cd1b74a613 100644 --- a/Jellyfin.Server/Migrations/MigrationOptions.cs +++ b/Jellyfin.Server/Migrations/MigrationOptions.cs @@ -16,7 +16,7 @@ namespace Jellyfin.Server.Migrations Applied = new List<(Guid Id, string Name)>(); } -// .Net xml serializer can't handle interfaces + // .Net xml serializer can't handle interfaces #pragma warning disable CA1002 // Do not expose generic lists /// <summary> /// Gets the list of applied migration routine names. diff --git a/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs b/Jellyfin.Server/Migrations/Routines/20250420050000_DisableTranscodingThrottling.cs index acf2835fe0..acf2835fe0 100644 --- a/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420050000_DisableTranscodingThrottling.cs diff --git a/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs b/Jellyfin.Server/Migrations/Routines/20250420060000_CreateUserLoggingConfigFile.cs index 1326a6dc8d..1326a6dc8d 100644 --- a/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420060000_CreateUserLoggingConfigFile.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs b/Jellyfin.Server/Migrations/Routines/20250420070000_MigrateActivityLogDb.cs index 8c8563190d..8c8563190d 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420070000_MigrateActivityLogDb.cs diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs b/Jellyfin.Server/Migrations/Routines/20250420080000_RemoveDuplicateExtras.cs index c9e66d0cfe..c9e66d0cfe 100644 --- a/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420080000_RemoveDuplicateExtras.cs diff --git a/Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/20250420090000_AddDefaultPluginRepository.cs index 8c8398a161..8c8398a161 100644 --- a/Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420090000_AddDefaultPluginRepository.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/20250420100000_MigrateUserDb.cs index 8c3361ee16..8c3361ee16 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420100000_MigrateUserDb.cs diff --git a/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/20250420110000_ReaddDefaultPluginRepository.cs index ebf4a2780e..ebf4a2780e 100644 --- a/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420110000_ReaddDefaultPluginRepository.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/20250420120000_MigrateDisplayPreferencesDb.cs index ffd06fea0d..ffd06fea0d 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420120000_MigrateDisplayPreferencesDb.cs diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs b/Jellyfin.Server/Migrations/Routines/20250420130000_RemoveDownloadImagesInAdvance.cs index b626c473e3..b626c473e3 100644 --- a/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420130000_RemoveDownloadImagesInAdvance.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs b/Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs index 0de775e03a..cbdc6efb44 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using Emby.Server.Implementations.Data; using Jellyfin.Database.Implementations; @@ -79,9 +80,9 @@ namespace Jellyfin.Server.Migrations.Routines foreach (var row in authenticatedDevices) { var dateCreatedStr = row.GetString(9); - _ = DateTime.TryParse(dateCreatedStr, out var dateCreated); + _ = DateTime.TryParse(dateCreatedStr, CultureInfo.InvariantCulture, out var dateCreated); var dateLastActivityStr = row.GetString(10); - _ = DateTime.TryParse(dateLastActivityStr, out var dateLastActivity); + _ = DateTime.TryParse(dateLastActivityStr, CultureInfo.InvariantCulture, out var dateLastActivity); if (row.IsDBNull(6)) { diff --git a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs b/Jellyfin.Server/Migrations/Routines/20250420150000_FixPlaylistOwner.cs index 56614ece3c..56614ece3c 100644 --- a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420150000_FixPlaylistOwner.cs diff --git a/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs b/Jellyfin.Server/Migrations/Routines/20250420160000_AddDefaultCastReceivers.cs index 00d152b4b8..00d152b4b8 100644 --- a/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420160000_AddDefaultCastReceivers.cs diff --git a/Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/20250420170000_UpdateDefaultPluginRepository.cs index f58cf27413..f58cf27413 100644 --- a/Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420170000_UpdateDefaultPluginRepository.cs diff --git a/Jellyfin.Server/Migrations/Routines/FixAudioData.cs b/Jellyfin.Server/Migrations/Routines/20250420180000_FixAudioData.cs index d102e24b91..d102e24b91 100644 --- a/Jellyfin.Server/Migrations/Routines/FixAudioData.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420180000_FixAudioData.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs b/Jellyfin.Server/Migrations/Routines/20250420193000_MigrateLibraryDbCompatibilityCheck.cs index d4cc9bbeed..d4cc9bbeed 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420193000_MigrateLibraryDbCompatibilityCheck.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/20250420200000_MigrateLibraryDb.cs index 3e4205547a..3e4205547a 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420200000_MigrateLibraryDb.cs diff --git a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs b/Jellyfin.Server/Migrations/Routines/20250420210000_MoveExtractedFiles.cs index fbf9c16377..cfc1628782 100644 --- a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420210000_MoveExtractedFiles.cs @@ -144,6 +144,11 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine } var newSubtitleCachePath = _pathManager.GetSubtitlePath(itemIdString, mediaStreamIndex, extension); + if (newSubtitleCachePath is null) + { + continue; + } + if (File.Exists(newSubtitleCachePath)) { File.Delete(oldSubtitleCachePath); @@ -182,6 +187,11 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine } var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.Filename ?? attachmentIndex.ToString(CultureInfo.InvariantCulture)); + if (newAttachmentPath is null) + { + continue; + } + if (File.Exists(newAttachmentPath)) { File.Delete(oldAttachmentPath); diff --git a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs b/Jellyfin.Server/Migrations/Routines/20250420230000_MoveTrickplayFiles.cs index 79a8f9577c..79a8f9577c 100644 --- a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420230000_MoveTrickplayFiles.cs diff --git a/Jellyfin.Server/Migrations/Routines/RefreshInternalDateModified.cs b/Jellyfin.Server/Migrations/Routines/20250420230000_RefreshInternalDateModified.cs index b23a7dbc42..b23a7dbc42 100644 --- a/Jellyfin.Server/Migrations/Routines/RefreshInternalDateModified.cs +++ b/Jellyfin.Server/Migrations/Routines/20250420230000_RefreshInternalDateModified.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs b/Jellyfin.Server/Migrations/Routines/20250421000000_MigrateKeyframeData.cs index aa55309264..aa55309264 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs +++ b/Jellyfin.Server/Migrations/Routines/20250421000000_MigrateKeyframeData.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs b/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs index 8a0a1741f1..8a0a1741f1 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs +++ b/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs diff --git a/Jellyfin.Server/Migrations/Routines/FixDates.cs b/Jellyfin.Server/Migrations/Routines/20250620180000_FixDates.cs index a5b11b11d0..a5b11b11d0 100644 --- a/Jellyfin.Server/Migrations/Routines/FixDates.cs +++ b/Jellyfin.Server/Migrations/Routines/20250620180000_FixDates.cs diff --git a/Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs b/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs index 502763ac09..502763ac09 100644 --- a/Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs +++ b/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs diff --git a/Jellyfin.Server/Migrations/Routines/CleanMusicArtist.cs b/Jellyfin.Server/Migrations/Routines/20251009200000_CleanMusicArtist.cs index d5c5f3d929..d5c5f3d929 100644 --- a/Jellyfin.Server/Migrations/Routines/CleanMusicArtist.cs +++ b/Jellyfin.Server/Migrations/Routines/20251009200000_CleanMusicArtist.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs index 14ae535531..4a6e74c229 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs +++ b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs @@ -7,6 +7,7 @@ using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -62,7 +63,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine var itemsWithData = context.BaseItems .Where(b => b.Data != null && (containerTypes.Contains(b.Type) || videoTypes.Contains(b.Type))) - .Select(b => new { b.Id, b.Data, b.Type }) + .Select(b => new { b.Id, b.Data, b.Type, b.Path, b.IsFolder }) .ToList(); _logger.LogInformation("Found {Count} potential items with LinkedChildren data to process.", itemsWithData.Count); @@ -73,6 +74,15 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine .GroupBy(b => b.Path!) .ToDictionary(g => g.Key, g => g.First().Id); + // Needed to tell a stale cached ItemId apart from one that still points at a real item. + var allItemIds = context.BaseItems.Select(b => b.Id).ToHashSet(); + + var playlistParentIds = itemsWithData + .Where(b => b.Type == "MediaBrowser.Controller.Playlists.Playlist") + .Select(b => b.Id) + .ToHashSet(); + + var droppedChildren = 0; var linkedChildrenToAdd = new List<LinkedChildEntity>(); var processedCount = 0; const int progressLogStep = 1000; @@ -99,7 +109,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine // Handle Video alternate versions if (isVideo) { - ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, linkedChildrenToAdd); + ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, allItemIds, linkedChildrenToAdd); } // Handle LinkedChildren (for containers and other items) @@ -109,46 +119,22 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine continue; } - var isPlaylist = item.Type == "MediaBrowser.Controller.Playlists.Playlist"; + // Legacy entries may hold a path relative to the container that holds them, so the + // container's own location has to be a real path, not a virtual one. + var itemPath = item.Path is null ? null : _appHost.ExpandVirtualPath(item.Path); + var containingFolderPath = item.IsFolder ? itemPath : Path.GetDirectoryName(itemPath); var sortOrder = 0; foreach (var childElement in linkedChildrenElement.EnumerateArray()) { - Guid? childId = null; - if (childElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null) - { - var itemIdStr = itemIdProp.GetString(); - if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId)) - { - childId = parsedId; - } - } - - if (!childId.HasValue || childId.Value.IsEmpty()) - { - if (childElement.TryGetProperty("Path", out var pathProp)) - { - var path = pathProp.GetString(); - if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId)) - { - childId = resolvedId; - } - } - } - - if (!childId.HasValue || childId.Value.IsEmpty()) - { - if (childElement.TryGetProperty("LibraryItemId", out var libIdProp)) - { - var libIdStr = libIdProp.GetString(); - if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId)) - { - childId = parsedLibId; - } - } - } - - if (!childId.HasValue || childId.Value.IsEmpty()) + var childId = ResolveChildId(childElement, containingFolderPath, pathToIdMap, allItemIds); + if (!childId.HasValue) { + droppedChildren++; + _logger.LogWarning( + "Dropping unresolvable LinkedChild of {ParentId}: ItemId {ItemId}, path {ChildPath}", + item.Id, + GetStringProperty(childElement, "ItemId") ?? "none", + GetStringProperty(childElement, "Path") ?? "none"); continue; } @@ -174,7 +160,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine ParentId = item.Id, ChildId = childId.Value, ChildType = childType, - SortOrder = isPlaylist ? sortOrder : null + SortOrder = sortOrder }); sortOrder++; @@ -196,23 +182,37 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine .Select(lc => new { lc.ParentId, lc.ChildId }) .ToHashSet(); + // A playlist may list the same child more than once, so it cannot be keyed by + // (ParentId, ChildId): skip a playlist wholesale if it already has rows instead, which + // keeps the routine re-runnable without collapsing repeated entries. + var populatedParentIds = context.LinkedChildren + .Select(lc => lc.ParentId) + .Distinct() + .ToHashSet(); + var toInsert = linkedChildrenToAdd - .Where(lc => !existingKeys.Contains(new { lc.ParentId, lc.ChildId })) + .Where(lc => playlistParentIds.Contains(lc.ParentId) + ? !populatedParentIds.Contains(lc.ParentId) + : !existingKeys.Contains(new { lc.ParentId, lc.ChildId })) .ToList(); if (toInsert.Count > 0) { - // Deduplicate by composite key (ParentId, ChildId) + // Every container type other than a playlist keeps a single entry per child. // Priority: LocalAlternateVersion > LinkedAlternateVersion > Other - toInsert = toInsert - .OrderBy(lc => lc.ChildType switch - { - LinkedChildType.LocalAlternateVersion => 0, - LinkedChildType.LinkedAlternateVersion => 1, - _ => 2 - }) - .DistinctBy(lc => new { lc.ParentId, lc.ChildId }) - .ToList(); + toInsert = + [ + .. toInsert.Where(lc => playlistParentIds.Contains(lc.ParentId)), + .. toInsert + .Where(lc => !playlistParentIds.Contains(lc.ParentId)) + .OrderBy(lc => lc.ChildType switch + { + LinkedChildType.LocalAlternateVersion => 0, + LinkedChildType.LinkedAlternateVersion => 1, + _ => 2 + }) + .DistinctBy(lc => new { lc.ParentId, lc.ChildId }) + ]; var childIds = toInsert.Select(lc => lc.ChildId).Distinct().ToList(); var existingChildIds = context.BaseItems @@ -222,6 +222,35 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine toInsert = toInsert.Where(lc => existingChildIds.Contains(lc.ChildId)).ToList(); + // Drop linked (user-merged) entries that point at items the parent owns (local + // file-based alternates or extras). These stem from legacy data that merged an + // owned item onto its own primary and would wrongly mark server-merged groups + // as user-merged (splittable). + var linkedChildIds = toInsert + .Where(lc => lc.ChildType == LinkedChildType.LinkedAlternateVersion) + .Select(lc => lc.ChildId) + .Distinct() + .ToList(); + + if (linkedChildIds.Count > 0) + { + var ownerIdByChildId = context.BaseItems + .WhereOneOrMany(linkedChildIds, b => b.Id) + .Where(b => b.OwnerId.HasValue) + .Select(b => new { b.Id, b.OwnerId }) + .ToDictionary(b => b.Id, b => b.OwnerId!.Value); + + var removedCount = toInsert.RemoveAll(lc => + lc.ChildType == LinkedChildType.LinkedAlternateVersion + && ownerIdByChildId.TryGetValue(lc.ChildId, out var ownerId) + && ownerId.Equals(lc.ParentId)); + + if (removedCount > 0) + { + _logger.LogInformation("Skipped {Count} LinkedAlternateVersion records pointing at items owned by their parent.", removedCount); + } + } + context.LinkedChildren.AddRange(toInsert); context.SaveChanges(); @@ -237,7 +266,10 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine _logger.LogInformation("No LinkedChildren data found to migrate."); } - _logger.LogInformation("LinkedChildren migration completed. Processed {Count} items.", processedCount); + _logger.LogInformation( + "LinkedChildren migration completed. Processed {Count} items, dropped {DroppedCount} unresolvable children.", + processedCount, + droppedChildren); CleanupWrongTypeAlternateVersions(context); CleanupOrphanedAlternateVersionBaseItems(context); @@ -283,9 +315,9 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine .Select(id => _libraryManager.GetItemById(id)) .Where(item => item is not null) .ToList(); - _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + var deleted = DeleteItems(itemsToDelete!); - _logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", itemsToDelete.Count); + _logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", deleted); } private void CleanupOrphanedAlternateVersionBaseItems(JellyfinDbContext context) @@ -314,9 +346,9 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine .Select(id => _libraryManager.GetItemById(id)) .Where(item => item is not null) .ToList(); - _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + var deleted = DeleteItems(itemsToDelete!); - _logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", itemsToDelete.Count); + _logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", deleted); } private void CleanupItemsFromDeletedLibraries(JellyfinDbContext context) @@ -343,9 +375,9 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine .Select(id => _libraryManager.GetItemById(id)) .Where(item => item is not null) .ToList(); - _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + var deleted = DeleteItems(itemsToDelete!); - _logger.LogInformation("Removed {Count} items from deleted libraries.", itemsToDelete.Count); + _logger.LogInformation("Removed {Count} items from deleted libraries.", deleted); } private void CleanupStaleFileEntries(JellyfinDbContext context) @@ -388,6 +420,12 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine var internalMetadataPath = _appPaths.InternalMetadataPath; + // An item outside every library location is normally left over from a removed media path, but + // it looks exactly the same as one whose storage failed to mount (a wrong bind mount on the + // first container start, for example). Only act on it while every location is readable. + var canRemoveUnrootedItems = inaccessiblePaths.Count == 0; + var skippedUnrootedItems = 0; + var staleIds = new List<Guid>(); foreach (var item in itemsWithPaths) { @@ -406,6 +444,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine // Directory check covers BDMV/DVD items whose Path points to a folder if (!File.Exists(path) && !Directory.Exists(path)) { + _logger.LogDebug("Removing item {ItemId}: file {Path} no longer exists.", item.Id, path); staleIds.Add(item.Id); } } @@ -413,12 +452,28 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine { // Item is not under ANY library location (accessible or not) — // it's orphaned from all libraries (e.g. media path was removed from config) - staleIds.Add(item.Id); + if (canRemoveUnrootedItems) + { + _logger.LogDebug("Removing item {ItemId}: path {Path} is outside every library location.", item.Id, path); + staleIds.Add(item.Id); + } + else + { + skippedUnrootedItems++; + } } // Otherwise: item is under an inaccessible location — skip (storage may be offline) } + if (skippedUnrootedItems > 0) + { + _logger.LogWarning( + "Keeping {Count} items that are outside every library location because {LocationCount} library location(s) are currently unavailable.", + skippedUnrootedItems, + inaccessiblePaths.Count); + } + if (staleIds.Count == 0) { _logger.LogInformation("No stale items found."); @@ -431,9 +486,34 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine .Select(id => _libraryManager.GetItemById(id)) .Where(item => item is not null) .ToList(); - _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + var deleted = DeleteItems(itemsToDelete!); + + _logger.LogInformation("Removed {Count} stale items.", deleted); + } + + private int DeleteItems(IReadOnlyCollection<BaseItem> items) + { + if (items.Count == 0) + { + return 0; + } + + var options = new DeleteOptions { DeleteFileLocation = false, DeleteFromExternalProvider = false }; + var deleted = 0; + foreach (var item in items) + { + try + { + _libraryManager.DeleteItem(item, options); + deleted++; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Skipping item {ItemId} ({ItemName}): delete failed.", item.Id, item.Name ?? "Unknown"); + } + } - _logger.LogInformation("Removed {Count} stale items.", itemsToDelete.Count); + return deleted; } private void CleanupOrphanedLinkedChildren(JellyfinDbContext context) @@ -463,18 +543,86 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine orphanedLinkedChildren.AddRange(orphanedByParent); } - // Remove all orphaned records - var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.ChildId }).ToList(); + // Remove all orphaned records. Both queries can return the same row, and a playlist may hold + // several rows for one child, so the position is what identifies an entry here. + var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.SortOrder }).ToList(); context.LinkedChildren.RemoveRange(distinctOrphaned); context.SaveChanges(); _logger.LogInformation("Successfully removed {Count} orphaned LinkedChildren records.", distinctOrphaned.Count); } + /// <summary> + /// Resolves the item a legacy LinkedChild entry points at. + /// </summary> + private static Guid? ResolveChildId( + JsonElement childElement, + string? containingFolderPath, + Dictionary<string, Guid> pathToIdMap, + HashSet<Guid> allItemIds) + { + // Pre-12 data only cached ItemId and re-resolved it from the path whenever the cached value + // went stale (BaseItem.GetLinkedChild in 10.x). An id that no longer exists must therefore + // fall through to the path, or the entry is lost even though its file is still in the library. + if (TryGetGuidProperty(childElement, "ItemId", out var itemId) && allItemIds.Contains(itemId)) + { + return itemId; + } + + var path = GetStringProperty(childElement, "Path"); + if (!string.IsNullOrEmpty(path)) + { + if (pathToIdMap.TryGetValue(path, out var idByPath)) + { + return idByPath; + } + + // 10.x resolved entries relative to the container that holds them. + if (!Path.IsPathRooted(path) && !string.IsNullOrEmpty(containingFolderPath)) + { + string? absolutePath = null; + try + { + absolutePath = Path.GetFullPath(Path.Combine(containingFolderPath, path)); + } + catch (ArgumentException) + { + // Malformed path, nothing to resolve. + } + + if (absolutePath is not null && pathToIdMap.TryGetValue(absolutePath, out var idByAbsolutePath)) + { + return idByAbsolutePath; + } + } + } + + if (TryGetGuidProperty(childElement, "LibraryItemId", out var libraryItemId) && allItemIds.Contains(libraryItemId)) + { + return libraryItemId; + } + + return null; + } + + private static string? GetStringProperty(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + + private static bool TryGetGuidProperty(JsonElement element, string propertyName, out Guid value) + { + value = Guid.Empty; + var raw = GetStringProperty(element, propertyName); + + return !string.IsNullOrEmpty(raw) && Guid.TryParse(raw, out value) && !value.IsEmpty(); + } + private void ProcessVideoAlternateVersions( JsonElement root, Guid parentId, Dictionary<string, Guid> pathToIdMap, + HashSet<Guid> allItemIds, List<LinkedChildEntity> linkedChildrenToAdd) { int sortOrder = 0; @@ -527,45 +675,8 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine { foreach (var linkedChildElement in linkedAlternateVersionsElement.EnumerateArray()) { - Guid? childId = null; - - // Try to get ItemId - if (linkedChildElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null) - { - var itemIdStr = itemIdProp.GetString(); - if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId)) - { - childId = parsedId; - } - } - - // Try to get from Path if ItemId not available - if (!childId.HasValue || childId.Value.IsEmpty()) - { - if (linkedChildElement.TryGetProperty("Path", out var pathProp)) - { - var path = pathProp.GetString(); - if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId)) - { - childId = resolvedId; - } - } - } - - // Try LibraryItemId as fallback - if (!childId.HasValue || childId.Value.IsEmpty()) - { - if (linkedChildElement.TryGetProperty("LibraryItemId", out var libIdProp)) - { - var libIdStr = libIdProp.GetString(); - if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId)) - { - childId = parsedLibId; - } - } - } - - if (!childId.HasValue || childId.Value.IsEmpty()) + var childId = ResolveChildId(linkedChildElement, null, pathToIdMap, allItemIds); + if (!childId.HasValue) { _logger.LogWarning("Could not resolve LinkedAlternateVersion child ID for parent {ParentId}", parentId); continue; diff --git a/Jellyfin.Server/Migrations/Routines/CleanupOrphanedExtras.cs b/Jellyfin.Server/Migrations/Routines/20260113230000_CleanupOrphanedExtras.cs index 14abaa7317..e8eeb2da20 100644 --- a/Jellyfin.Server/Migrations/Routines/CleanupOrphanedExtras.cs +++ b/Jellyfin.Server/Migrations/Routines/20260113230000_CleanupOrphanedExtras.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations; +using Jellyfin.Server.Implementations.Item; using Jellyfin.Server.Migrations.Stages; using Jellyfin.Server.ServerSetupApp; using MediaBrowser.Controller.Channels; @@ -23,7 +24,7 @@ namespace Jellyfin.Server.Migrations.Routines; /// Removes orphaned extras (items with OwnerId pointing to non-existent items). /// Must run before EF migrations that add FK constraints on OwnerId. /// </summary> -[JellyfinMigration("2026-01-13T23:00:00", nameof(CleanupOrphanedExtras), Stage = JellyfinMigrationStageTypes.CoreInitialisation)] +[JellyfinMigration("2026-01-13T23:00:00", nameof(CleanupOrphanedExtras), Stage = JellyfinMigrationStageTypes.AppInitialisation)] [JellyfinMigrationBackup(JellyfinDb = true)] public class CleanupOrphanedExtras : IAsyncMigrationRoutine { @@ -37,39 +38,14 @@ public class CleanupOrphanedExtras : IAsyncMigrationRoutine /// <param name="logger">The startup logger.</param> /// <param name="dbContextFactory">The database context factory.</param> /// <param name="libraryManager">The library manager.</param> - /// <param name="itemRepository">The item repository.</param> - /// <param name="itemCountService">The item count service.</param> - /// <param name="channelManager">The channel manager.</param> - /// <param name="recordingsManager">The recordings manager.</param> - /// <param name="mediaSourceManager">The media source manager.</param> - /// <param name="mediaSegmentManager">The media segments manager.</param> - /// <param name="configurationManager">The configuration manager.</param> - /// <param name="fileSystem">The file system.</param> public CleanupOrphanedExtras( IStartupLogger<CleanupOrphanedExtras> logger, IDbContextFactory<JellyfinDbContext> dbContextFactory, - ILibraryManager libraryManager, - IItemRepository itemRepository, - IItemCountService itemCountService, - IChannelManager channelManager, - IRecordingsManager recordingsManager, - IMediaSourceManager mediaSourceManager, - IMediaSegmentManager mediaSegmentManager, - IServerConfigurationManager configurationManager, - IFileSystem fileSystem) + ILibraryManager libraryManager) { _logger = logger; _dbContextFactory = dbContextFactory; _libraryManager = libraryManager; - BaseItem.LibraryManager ??= libraryManager; - BaseItem.ItemRepository ??= itemRepository; - BaseItem.ItemCountService ??= itemCountService; - BaseItem.ChannelManager ??= channelManager; - BaseItem.MediaSourceManager ??= mediaSourceManager; - BaseItem.MediaSegmentManager ??= mediaSegmentManager; - BaseItem.ConfigurationManager ??= configurationManager; - BaseItem.FileSystem ??= fileSystem; - Video.RecordingsManager ??= recordingsManager; } /// <inheritdoc/> @@ -78,12 +54,19 @@ public class CleanupOrphanedExtras : IAsyncMigrationRoutine var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { + var placeholderOwner = Guid.Parse("00000000-0000-0000-0000-000000000001"); +#pragma warning disable RS0030 // Do not use banned APIs var orphanedItemIds = await context.BaseItems - .Where(b => b.OwnerId.HasValue && !b.OwnerId.Value.Equals(Guid.Empty)) - .Where(b => !context.BaseItems.Any(parent => parent.Id.Equals(b.OwnerId!.Value))) - .Select(b => b.Id) + .Where(b => b.OwnerId.HasValue && b.OwnerId == placeholderOwner) + .Select(b => new + { + b.Id, + b.Path, + b.Type + }) .ToListAsync(cancellationToken) .ConfigureAwait(false); +#pragma warning restore RS0030 // Do not use banned APIs if (orphanedItemIds.Count == 0) { @@ -93,20 +76,36 @@ public class CleanupOrphanedExtras : IAsyncMigrationRoutine _logger.LogInformation("Found {Count} orphaned extras to remove", orphanedItemIds.Count); - // Batch-resolve items for metadata path cleanup, then delete all at once - var itemsToDelete = new List<BaseItem>(); - foreach (var itemId in orphanedItemIds) + // Resolve items for metadata path cleanup, then delete in batches so we never issue one + // massive delete transaction and progress stays visible on large libraries. + _logger.LogInformation("Deleting {Count} orphaned extras...", orphanedItemIds.Count); + const int deleteBatchSize = 500; + var deletedSoFar = 0; + for (var offset = 0; offset < orphanedItemIds.Count; offset += deleteBatchSize) { - var item = _libraryManager.GetItemById(itemId); - if (item is not null) - { - itemsToDelete.Add(item); - } - } + cancellationToken.ThrowIfCancellationRequested(); - _libraryManager.DeleteItemsUnsafeFast(itemsToDelete); + var batch = orphanedItemIds.GetRange(offset, Math.Min(deleteBatchSize, orphanedItemIds.Count - offset)); + var itemsToDelete = batch + .Select(itemId => BaseItemMapper.DeserializeBaseItem( + new Database.Implementations.Entities.BaseItemEntity() + { + Id = itemId.Id, + Path = itemId.Path, + Type = itemId.Type + }, + _logger, + null, + true)!) + .ToList(); + + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete); + + deletedSoFar += batch.Count; + _logger.LogInformation("Deleting orphaned extras: {Deleted}/{Total}", deletedSoFar, orphanedItemIds.Count); + } - _logger.LogInformation("Successfully removed {Count} orphaned extras", itemsToDelete.Count); + _logger.LogInformation("Successfully removed {Count} orphaned extras", orphanedItemIds.Count); } } } diff --git a/Jellyfin.Server/Migrations/Routines/FixIncorrectOwnerIdRelationships.cs b/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs index 0baf261a2e..e34182fd5d 100644 --- a/Jellyfin.Server/Migrations/Routines/FixIncorrectOwnerIdRelationships.cs +++ b/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs @@ -136,19 +136,38 @@ public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine 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) + _logger.LogInformation("Deleting {Count} duplicate database entries...", allIdsToDelete.Count); + + // Delete in batches so progress is visible (item resolution and deletion can take a + // long time on large libraries) and so we never issue one massive delete transaction. + const int deleteBatchSize = 500; + var deletedSoFar = 0; + for (var offset = 0; offset < allIdsToDelete.Count; offset += deleteBatchSize) { - _persistenceService.DeleteItem(unresolvedIds); + cancellationToken.ThrowIfCancellationRequested(); + + var batchIds = allIdsToDelete.GetRange(offset, Math.Min(deleteBatchSize, allIdsToDelete.Count - offset)); + + // Resolve items for metadata path cleanup, then delete this batch + var itemsToDelete = batchIds + .Select(id => _libraryManager.GetItemById(id)) + .Where(item => item is not null) + .ToList(); + if (itemsToDelete.Count > 0) + { + _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 = batchIds.Where(id => !deletedIds.Contains(id)).ToList(); + if (unresolvedIds.Count > 0) + { + _persistenceService.DeleteItem(unresolvedIds); + } + + deletedSoFar += batchIds.Count; + _logger.LogInformation("Deleting duplicates: {Deleted}/{Total} items", deletedSoFar, allIdsToDelete.Count); } } diff --git a/Jellyfin.Server/Migrations/Routines/FixLibrarySubtitleDownloadLanguages.cs b/Jellyfin.Server/Migrations/Routines/20260206200000_FixLibrarySubtitleDownloadLanguages.cs index 2b1f549940..2b1f549940 100644 --- a/Jellyfin.Server/Migrations/Routines/FixLibrarySubtitleDownloadLanguages.cs +++ b/Jellyfin.Server/Migrations/Routines/20260206200000_FixLibrarySubtitleDownloadLanguages.cs diff --git a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs index ed92c34aa3..ed92c34aa3 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs +++ b/Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs diff --git a/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs b/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs new file mode 100644 index 0000000000..bff6ebbfb0 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs @@ -0,0 +1,216 @@ +#pragma warning disable RS0030 // Do not use banned APIs + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Merges MusicArtist records that differ only by Name casing. Prior to the case-insensitive +/// dedup lookup added alongside this migration, the artist validator would create a second +/// MusicArtist whenever a track tagged the artist with a different casing than the +/// resolver-created one (e.g. "Thirty Seconds To Mars" vs. "Thirty Seconds to Mars"). +/// </summary> +[JellyfinMigration("2026-05-08T12:00:00", nameof(MergeDuplicateMusicArtists))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class MergeDuplicateMusicArtists : IAsyncMigrationRoutine +{ + private const string MusicArtistType = "MediaBrowser.Controller.Entities.Audio.MusicArtist"; + + private readonly IStartupLogger<MergeDuplicateMusicArtists> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; + private readonly ILibraryManager _libraryManager; + private readonly IItemPersistenceService _persistenceService; + + /// <summary> + /// Initializes a new instance of the <see cref="MergeDuplicateMusicArtists"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="dbContextFactory">The database context factory.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="persistenceService">The item persistence service.</param> + public MergeDuplicateMusicArtists( + IStartupLogger<MergeDuplicateMusicArtists> logger, + IDbContextFactory<JellyfinDbContext> dbContextFactory, + ILibraryManager libraryManager, + IItemPersistenceService persistenceService) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + _libraryManager = libraryManager; + _persistenceService = persistenceService; + } + + /// <inheritdoc/> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + var artists = await context.BaseItems + .Where(b => b.Type == MusicArtistType && b.Name != null) + .Select(b => new { b.Id, b.Name, b.DateCreated }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var groups = artists + .GroupBy(a => a.Name!.ToLowerInvariant()) + .Where(g => g.Count() > 1) + .ToList(); + + if (groups.Count == 0) + { + _logger.LogInformation("No case-only duplicate MusicArtist records found."); + return; + } + + _logger.LogInformation("Found {Count} groups of case-only duplicate MusicArtist records.", groups.Count); + + var idsToDelete = new List<Guid>(); + foreach (var group in groups) + { + cancellationToken.ThrowIfCancellationRequested(); + + var groupIds = group.Select(g => g.Id).ToArray(); + + // Pick the keeper: the artist with the most child references is the "real" one + // (the resolver-created artist with a filesystem path); the duplicates are usually + // empty stubs created by the validator's case-sensitive miss. + var stats = await context.BaseItems + .Where(b => groupIds.Contains(b.Id)) + .Select(b => new + { + b.Id, + b.Name, + b.DateCreated, + ChildCount = context.BaseItems.Count(c => c.ParentId == b.Id), + AncestorCount = context.AncestorIds.Count(a => a.ParentItemId == b.Id), + LinkedCount = context.LinkedChildren.Count(l => l.ParentId == b.Id || l.ChildId == b.Id), + }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var keeper = stats + .OrderByDescending(s => s.ChildCount) + .ThenByDescending(s => s.AncestorCount) + .ThenByDescending(s => s.LinkedCount) + .ThenBy(s => s.DateCreated) + .First(); + + foreach (var dup in stats.Where(s => s.Id != keeper.Id)) + { + var keeperId = keeper.Id; + var dupId = dup.Id; + + await context.BaseItems + .Where(b => b.ParentId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(b => b.ParentId, keeperId), cancellationToken) + .ConfigureAwait(false); + + await context.BaseItems + .Where(b => b.OwnerId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(b => b.OwnerId, keeperId), cancellationToken) + .ConfigureAwait(false); + + // AncestorIds PK is (ItemId, ParentItemId); drop rows that would collide before redirecting. + await context.AncestorIds + .Where(a => a.ParentItemId == dupId + && context.AncestorIds.Any(k => k.ParentItemId == keeperId && k.ItemId == a.ItemId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.AncestorIds + .Where(a => a.ParentItemId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(a => a.ParentItemId, keeperId), cancellationToken) + .ConfigureAwait(false); + + // LinkedChildren PK is (ParentId, ChildId); drop colliding rows in both directions. + await context.LinkedChildren + .Where(l => l.ParentId == dupId + && context.LinkedChildren.Any(k => k.ParentId == keeperId && k.ChildId == l.ChildId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ParentId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(l => l.ParentId, keeperId), cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ChildId == dupId + && context.LinkedChildren.Any(k => k.ChildId == keeperId && k.ParentId == l.ParentId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ChildId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(l => l.ChildId, keeperId), cancellationToken) + .ConfigureAwait(false); + + // UserData has UNIQUE(UserId, CustomDataKey); keep the dup's row only when the + // keeper has no equivalent row, otherwise the keeper's value wins. + await context.UserData + .Where(u => u.ItemId == dupId + && context.UserData.Any(k => k.ItemId == keeperId && k.UserId == u.UserId && k.CustomDataKey == u.CustomDataKey)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.UserData + .Where(u => u.ItemId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(u => u.ItemId, keeperId), cancellationToken) + .ConfigureAwait(false); + + idsToDelete.Add(dupId); + } + + _logger.LogDebug( + "Merged duplicates for '{Name}' into {KeeperId} ({Removed} removed).", + keeper.Name, + keeper.Id, + stats.Count - 1); + } + + if (idsToDelete.Count == 0) + { + return; + } + + // Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the + // %MetadataPath%/artists/<Name> directories that the duplicate stubs left behind. + // Fall back to the persistence service for any items the LibraryManager can't resolve. + // Delete in batches so we never issue one massive delete transaction and progress stays visible. + _logger.LogInformation("Deleting {Count} duplicate MusicArtist records...", idsToDelete.Count); + const int deleteBatchSize = 500; + var deletedSoFar = 0; + for (var offset = 0; offset < idsToDelete.Count; offset += deleteBatchSize) + { + cancellationToken.ThrowIfCancellationRequested(); + + var batchIds = idsToDelete.GetRange(offset, Math.Min(deleteBatchSize, idsToDelete.Count - offset)); + + var itemsToDelete = batchIds + .Select(id => _libraryManager.GetItemById(id)) + .Where(item => item is not null) + .ToList(); + if (itemsToDelete.Count > 0) + { + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + } + + var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); + var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList(); + if (unresolvedIds.Count > 0) + { + _persistenceService.DeleteItem(unresolvedIds); + } + + deletedSoFar += batchIds.Count; + _logger.LogInformation("Deleting duplicate MusicArtist records: {Deleted}/{Total}", deletedSoFar, idsToDelete.Count); + } + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs b/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs new file mode 100644 index 0000000000..f28c804d26 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs @@ -0,0 +1,312 @@ +#pragma warning disable RS0030 // Do not use banned APIs + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Merges case-only duplicate people. Two passes: +/// 1) Person BaseItems whose Name differs only by casing — Person.GetPath hashes the name +/// verbatim, so two casings produce two distinct Person rows in BaseItems. +/// 2) Peoples lookup rows whose Name differs only by casing within the same PersonType — +/// UpdatePeople used to insert a second Peoples row when a metadata provider returned +/// a different casing than the row already in the table. +/// Both bugs cause the /Persons endpoint to list the same person twice. +/// </summary> +[JellyfinMigration("2026-05-08T13:00:00", nameof(MergeDuplicatePeople))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class MergeDuplicatePeople : IAsyncMigrationRoutine +{ + private const string PersonType = "MediaBrowser.Controller.Entities.Person"; + + private readonly IStartupLogger<MergeDuplicatePeople> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; + private readonly ILibraryManager _libraryManager; + private readonly IItemPersistenceService _persistenceService; + + /// <summary> + /// Initializes a new instance of the <see cref="MergeDuplicatePeople"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="dbContextFactory">The database context factory.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="persistenceService">The item persistence service.</param> + public MergeDuplicatePeople( + IStartupLogger<MergeDuplicatePeople> logger, + IDbContextFactory<JellyfinDbContext> dbContextFactory, + ILibraryManager libraryManager, + IItemPersistenceService persistenceService) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + _libraryManager = libraryManager; + _persistenceService = persistenceService; + } + + /// <inheritdoc/> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + await MergePersonBaseItemsAsync(context, cancellationToken).ConfigureAwait(false); + await MergePeoplesRowsAsync(context, cancellationToken).ConfigureAwait(false); + } + } + + private async Task MergePersonBaseItemsAsync(JellyfinDbContext context, CancellationToken cancellationToken) + { + var persons = await context.BaseItems + .Where(b => b.Type == PersonType && b.Name != null) + .Select(b => new { b.Id, b.Name, b.DateCreated }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var groups = persons + .GroupBy(p => p.Name!.ToLowerInvariant()) + .Where(g => g.Count() > 1) + .ToList(); + + if (groups.Count == 0) + { + _logger.LogInformation("No case-only duplicate Person BaseItems found."); + return; + } + + _logger.LogInformation("Found {Count} groups of case-only duplicate Person BaseItems.", groups.Count); + + var idsToDelete = new List<Guid>(); + foreach (var group in groups) + { + cancellationToken.ThrowIfCancellationRequested(); + + var groupIds = group.Select(g => g.Id).ToArray(); + + // Pick the keeper: the Person with the most UserData rows (favorites, image + // refresh state) is the one users have actually interacted with. + var stats = await context.BaseItems + .Where(b => groupIds.Contains(b.Id)) + .Select(b => new + { + b.Id, + b.Name, + b.DateCreated, + UserDataCount = context.UserData.Count(u => u.ItemId == b.Id), + LinkedCount = context.LinkedChildren.Count(l => l.ParentId == b.Id || l.ChildId == b.Id), + }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var keeper = stats + .OrderByDescending(s => s.UserDataCount) + .ThenByDescending(s => s.LinkedCount) + .ThenBy(s => s.DateCreated) + .First(); + + foreach (var dup in stats.Where(s => s.Id != keeper.Id)) + { + var keeperId = keeper.Id; + var dupId = dup.Id; + + await context.BaseItems + .Where(b => b.ParentId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(b => b.ParentId, keeperId), cancellationToken) + .ConfigureAwait(false); + + await context.BaseItems + .Where(b => b.OwnerId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(b => b.OwnerId, keeperId), cancellationToken) + .ConfigureAwait(false); + + await context.AncestorIds + .Where(a => a.ParentItemId == dupId + && context.AncestorIds.Any(k => k.ParentItemId == keeperId && k.ItemId == a.ItemId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.AncestorIds + .Where(a => a.ParentItemId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(a => a.ParentItemId, keeperId), cancellationToken) + .ConfigureAwait(false); + + await context.LinkedChildren + .Where(l => l.ParentId == dupId + && context.LinkedChildren.Any(k => k.ParentId == keeperId && k.ChildId == l.ChildId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ParentId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(l => l.ParentId, keeperId), cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ChildId == dupId + && context.LinkedChildren.Any(k => k.ChildId == keeperId && k.ParentId == l.ParentId)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.LinkedChildren + .Where(l => l.ChildId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(l => l.ChildId, keeperId), cancellationToken) + .ConfigureAwait(false); + + await context.UserData + .Where(u => u.ItemId == dupId + && context.UserData.Any(k => k.ItemId == keeperId && k.UserId == u.UserId && k.CustomDataKey == u.CustomDataKey)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.UserData + .Where(u => u.ItemId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(u => u.ItemId, keeperId), cancellationToken) + .ConfigureAwait(false); + + idsToDelete.Add(dupId); + } + + _logger.LogDebug( + "Merged Person BaseItems for '{Name}' into {KeeperId} ({Removed} removed).", + keeper.Name, + keeper.Id, + stats.Count - 1); + } + + if (idsToDelete.Count == 0) + { + return; + } + + // Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the + // %MetadataPath%/People/<Letter>/<Name> directories the duplicate stubs left behind. + // Delete in batches so we never issue one massive delete transaction and progress stays visible. + _logger.LogInformation("Deleting {Count} duplicate Person BaseItems...", idsToDelete.Count); + const int deleteBatchSize = 500; + var deletedSoFar = 0; + for (var offset = 0; offset < idsToDelete.Count; offset += deleteBatchSize) + { + cancellationToken.ThrowIfCancellationRequested(); + + var batchIds = idsToDelete.GetRange(offset, Math.Min(deleteBatchSize, idsToDelete.Count - offset)); + + var itemsToDelete = batchIds + .Select(id => _libraryManager.GetItemById(id)) + .Where(item => item is not null) + .ToList(); + if (itemsToDelete.Count > 0) + { + _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); + } + + var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); + var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList(); + if (unresolvedIds.Count > 0) + { + _persistenceService.DeleteItem(unresolvedIds); + } + + deletedSoFar += batchIds.Count; + _logger.LogInformation("Deleting duplicate Person BaseItems: {Deleted}/{Total}", deletedSoFar, idsToDelete.Count); + } + } + + private async Task MergePeoplesRowsAsync(JellyfinDbContext context, CancellationToken cancellationToken) + { + var people = await context.Peoples + .Select(p => new { p.Id, p.Name, p.PersonType }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var groups = people + .GroupBy(p => (Name: p.Name.ToLowerInvariant(), p.PersonType)) + .Where(g => g.Count() > 1) + .ToList(); + + if (groups.Count == 0) + { + _logger.LogInformation("No case-only duplicate Peoples rows found."); + return; + } + + _logger.LogInformation("Found {Count} groups of case-only duplicate Peoples rows.", groups.Count); + + var idsToDelete = new List<Guid>(); + foreach (var group in groups) + { + cancellationToken.ThrowIfCancellationRequested(); + + var groupIds = group.Select(g => g.Id).ToArray(); + + // Pick the keeper: the row referenced by the most BaseItems is the one most + // tracks/movies already point at; the duplicates are usually orphan stubs left + // by a casing-mismatched insert. + var stats = await context.Peoples + .Where(p => groupIds.Contains(p.Id)) + .Select(p => new + { + p.Id, + p.Name, + MapCount = context.PeopleBaseItemMap.Count(m => m.PeopleId == p.Id), + }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var keeper = stats + .OrderByDescending(s => s.MapCount) + .ThenBy(s => s.Id) + .First(); + + foreach (var dup in stats.Where(s => s.Id != keeper.Id)) + { + var keeperId = keeper.Id; + var dupId = dup.Id; + + // PeopleBaseItemMap PK is (ItemId, PeopleId, Role); drop dup rows that would + // collide on (ItemId, Role) before redirecting PeopleId. Role is nullable, so + // match nulls explicitly. + await context.PeopleBaseItemMap + .Where(m => m.PeopleId == dupId + && context.PeopleBaseItemMap.Any(k => k.PeopleId == keeperId + && k.ItemId == m.ItemId + && (k.Role == m.Role || (k.Role == null && m.Role == null)))) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + await context.PeopleBaseItemMap + .Where(m => m.PeopleId == dupId) + .ExecuteUpdateAsync(s => s.SetProperty(m => m.PeopleId, keeperId), cancellationToken) + .ConfigureAwait(false); + + idsToDelete.Add(dupId); + } + + _logger.LogDebug( + "Merged Peoples rows for '{Name}' into {KeeperId} ({Removed} removed).", + keeper.Name, + keeper.Id, + stats.Count - 1); + } + + if (idsToDelete.Count == 0) + { + return; + } + + var idx = 0; + foreach (var item in idsToDelete.Chunk(200)) + { + idx++; // humans count at one + _logger.LogInformation("Remove batch {BatchNo}/{MaxBatches} duplicate Peoples.", idx, idsToDelete.Count / 200); + await context.Peoples + .Where(p => item.Contains(p.Id)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + } + + _logger.LogInformation("Removed {Count} duplicate Peoples rows.", idsToDelete.Count); + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260522092304_UpdateNormalizedUsername.cs b/Jellyfin.Server/Migrations/Routines/20260522092304_UpdateNormalizedUsername.cs new file mode 100644 index 0000000000..8100d4759e --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260522092304_UpdateNormalizedUsername.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using MediaBrowser.Controller.Configuration; +using Microsoft.EntityFrameworkCore; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Part 2 Migration for NormalisedUsername. +/// </summary> +[JellyfinMigration("2026-05-22T09:23:04", nameof(UpdateNormalizedUsername), Stage = Stages.JellyfinMigrationStageTypes.CoreInitialisation)] +#pragma warning disable SA1649 // File name should match first type name +public class UpdateNormalizedUsername : IAsyncMigrationRoutine +#pragma warning restore SA1649 // File name should match first type name +{ + private readonly IDbContextFactory<JellyfinDbContext> _contextFactory; + + /// <summary> + /// Initializes a new instance of the <see cref="UpdateNormalizedUsername"/> class. + /// </summary> + /// <param name="contextFactory">Db Context factory.</param> + public UpdateNormalizedUsername(IDbContextFactory<JellyfinDbContext> contextFactory) + { + _contextFactory = contextFactory; + } + + /// <inheritdoc/> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var dbContext = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var users = await dbContext.Users.ToListAsync(cancellationToken).ConfigureAwait(false); + foreach (var user in users) + { + user.NormalizedUsername = user.Username.ToUpperInvariant(); + } + + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs b/Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs new file mode 100644 index 0000000000..d8dfe181ca --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Removes on-disk external item data (attachments, subtitles, trickplay tiles, chapter images) for items that +/// no longer exist in the <c>BaseItems</c> table. The database side is cleaned up synchronously by +/// <c>IItemPersistenceService.DeleteItem</c>, so the leftover orphans live on the filesystem. +/// </summary> +[JellyfinMigration("2026-05-25T01:00:00", nameof(CleanupOrphanedExternalData))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class CleanupOrphanedExternalData : IAsyncMigrationRoutine +{ + private const int ProgressLogStep = 500; + + private readonly IStartupLogger<CleanupOrphanedExternalData> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory; + private readonly IApplicationPaths _appPaths; + private readonly IServerApplicationPaths _serverPaths; + + /// <summary> + /// Initializes a new instance of the <see cref="CleanupOrphanedExternalData"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="dbContextFactory">The database context factory.</param> + /// <param name="appPaths">The application paths.</param> + /// <param name="serverPaths">The server application paths.</param> + public CleanupOrphanedExternalData( + IStartupLogger<CleanupOrphanedExternalData> logger, + IDbContextFactory<JellyfinDbContext> dbContextFactory, + IApplicationPaths appPaths, + IServerApplicationPaths serverPaths) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + _appPaths = appPaths; + _serverPaths = serverPaths; + } + + /// <inheritdoc/> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var knownIds = await LoadKnownItemIdsAsync(cancellationToken).ConfigureAwait(false); + + CleanupGuidIndexedRoot( + "attachment", + Path.Combine(_appPaths.DataPath, "attachments"), + knownIds, + deleteSubPath: null, + cancellationToken); + + CleanupGuidIndexedRoot( + "subtitle", + Path.Combine(_appPaths.DataPath, "subtitles"), + knownIds, + deleteSubPath: null, + cancellationToken); + + CleanupGuidIndexedRoot( + "trickplay", + _appPaths.TrickplayPath, + knownIds, + deleteSubPath: null, + cancellationToken); + + CleanupGuidIndexedRoot( + "chapter image", + Path.Combine(_serverPaths.InternalMetadataPath, "library"), + knownIds, + deleteSubPath: "chapters", + cancellationToken); + } + + private async Task<HashSet<Guid>> LoadKnownItemIdsAsync(CancellationToken cancellationToken) + { + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) + { + var ids = await context.BaseItems + .AsNoTracking() + .Select(b => b.Id) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + return [.. ids]; + } + } + + private void CleanupGuidIndexedRoot( + string label, + string root, + HashSet<Guid> knownIds, + string? deleteSubPath, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) + { + _logger.LogInformation("Skipping {Label} cleanup; root {Root} does not exist", label, root); + return; + } + + _logger.LogInformation("Scanning for orphaned {Label} data under {Root}", label, root); + + var scanned = 0; + var removed = 0; + foreach (var prefixDir in Directory.EnumerateDirectories(root)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var prefixName = Path.GetFileName(prefixDir); + if (prefixName.Length != 2) + { + continue; + } + + foreach (var guidDir in Directory.EnumerateDirectories(prefixDir)) + { + cancellationToken.ThrowIfCancellationRequested(); + + scanned++; + if (scanned % ProgressLogStep == 0) + { + _logger.LogInformation("Scanning {Label}: {Scanned} directories examined, {Removed} orphans removed so far", label, scanned, removed); + } + + var leafName = Path.GetFileName(guidDir); + if (!Guid.TryParse(leafName, CultureInfo.InvariantCulture, out var id)) + { + continue; + } + + if (knownIds.Contains(id)) + { + continue; + } + + var target = deleteSubPath is null ? guidDir : Path.Combine(guidDir, deleteSubPath); + if (deleteSubPath is not null && !Directory.Exists(target)) + { + continue; + } + + if (TryDelete(target)) + { + removed++; + } + } + } + + _logger.LogInformation("Finished {Label} cleanup: scanned {Scanned} directories, removed {Removed} orphans", label, scanned, removed); + } + + private bool TryDelete(string dir) + { + try + { + Directory.Delete(dir, recursive: true); + return true; + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Failed to delete orphaned directory {Dir}", dir); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Permission denied deleting orphaned directory {Dir}", dir); + } + + return false; + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260531160000_DisableLegacyAuthorization.cs b/Jellyfin.Server/Migrations/Routines/20260531160000_DisableLegacyAuthorization.cs new file mode 100644 index 0000000000..4b8ced90ac --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260531160000_DisableLegacyAuthorization.cs @@ -0,0 +1,32 @@ +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Configuration; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Migration to disable legacy authorization in the system config. +/// </summary> +[JellyfinMigration("2026-05-31T16:00:00", nameof(DisableLegacyAuthorization), Stage = Stages.JellyfinMigrationStageTypes.CoreInitialisation)] +public class DisableLegacyAuthorization : IAsyncMigrationRoutine +{ + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="DisableLegacyAuthorization"/> class. + /// </summary> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public DisableLegacyAuthorization(IServerConfigurationManager serverConfigurationManager) + { + _serverConfigurationManager = serverConfigurationManager; + } + + /// <inheritdoc /> + public Task PerformAsync(CancellationToken cancellationToken) + { + _serverConfigurationManager.Configuration.EnableLegacyAuthorization = false; + _serverConfigurationManager.SaveConfiguration(); + + return Task.CompletedTask; + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260610120000_RefreshCleanNamesAndValues.cs b/Jellyfin.Server/Migrations/Routines/20260610120000_RefreshCleanNamesAndValues.cs new file mode 100644 index 0000000000..7ade727d9b --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260610120000_RefreshCleanNamesAndValues.cs @@ -0,0 +1,173 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Extensions; +using Jellyfin.Server.ServerSetupApp; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Migration to refresh CleanName values for all library items and CleanValue values for all item values. +/// </summary> +[JellyfinMigration("2026-06-10T12:00:00", nameof(RefreshCleanNamesAndValues))] +[JellyfinMigrationBackup(JellyfinDb = true)] +public class RefreshCleanNamesAndValues : IAsyncMigrationRoutine +{ + private readonly IStartupLogger<RefreshCleanNamesAndValues> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + + /// <summary> + /// Initializes a new instance of the <see cref="RefreshCleanNamesAndValues"/> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param> + public RefreshCleanNamesAndValues( + IStartupLogger<RefreshCleanNamesAndValues> logger, + IDbContextFactory<JellyfinDbContext> dbProvider) + { + _logger = logger; + _dbProvider = dbProvider; + } + + /// <inheritdoc /> + public async Task PerformAsync(CancellationToken cancellationToken) + { + await RefreshCleanNamesAsync(cancellationToken).ConfigureAwait(false); + await RefreshCleanValuesAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task RefreshCleanNamesAsync(CancellationToken cancellationToken) + { + const int Limit = 10000; + int itemCount = 0; + + var sw = Stopwatch.StartNew(); + + using var context = _dbProvider.CreateDbContext(); + var records = context.BaseItems.Count(b => !string.IsNullOrEmpty(b.Name)); + _logger.LogInformation("Refreshing CleanName for {Count} library items", records); + + var processedInPartition = 0; + + await foreach (var item in context.BaseItems + .Where(b => !string.IsNullOrEmpty(b.Name)) + .OrderBy(e => e.Id) + .WithPartitionProgress((partition) => _logger.LogInformation("Processed: {Offset}/{Total} - Updated: {UpdatedCount} - Time: {Elapsed}", partition * Limit, records, itemCount, sw.Elapsed)) + .PartitionEagerAsync(Limit, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + try + { + var newCleanName = string.IsNullOrWhiteSpace(item.Name) ? string.Empty : item.Name.GetCleanValue(); + if (!string.Equals(newCleanName, item.CleanName, StringComparison.Ordinal)) + { + _logger.LogDebug( + "Updating CleanName for item {Id}: '{OldValue}' -> '{NewValue}'", + item.Id, + item.CleanName, + newCleanName); + item.CleanName = newCleanName; + itemCount++; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update CleanName for item {Id} ({Name})", item.Id, item.Name); + } + + processedInPartition++; + + if (processedInPartition >= Limit) + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + // Clear tracked entities to avoid memory growth across partitions + context.ChangeTracker.Clear(); + processedInPartition = 0; + } + } + + // Save any remaining changes after the loop + if (processedInPartition > 0) + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + context.ChangeTracker.Clear(); + } + + _logger.LogInformation( + "Refreshed CleanName for {UpdatedCount} out of {TotalCount} items in {Time}", + itemCount, + records, + sw.Elapsed); + } + + private async Task RefreshCleanValuesAsync(CancellationToken cancellationToken) + { + const int Limit = 10000; + int itemCount = 0; + + var sw = Stopwatch.StartNew(); + + using var context = _dbProvider.CreateDbContext(); + var records = context.ItemValues.Count(b => !string.IsNullOrEmpty(b.Value)); + _logger.LogInformation("Refreshing CleanValue for {Count} item values", records); + + var processedInPartition = 0; + + await foreach (var item in context.ItemValues + .Where(b => !string.IsNullOrEmpty(b.Value)) + .OrderBy(e => e.ItemValueId) + .WithPartitionProgress((partition) => _logger.LogInformation("Processed: {Offset}/{Total} - Updated: {UpdatedCount} - Time: {Elapsed}", partition * Limit, records, itemCount, sw.Elapsed)) + .PartitionEagerAsync(Limit, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + try + { + var newCleanValue = string.IsNullOrWhiteSpace(item.Value) ? string.Empty : item.Value.GetCleanValue(); + if (!string.Equals(newCleanValue, item.CleanValue, StringComparison.Ordinal)) + { + _logger.LogDebug( + "Updating CleanValue for item value {Id}: '{OldValue}' -> '{NewValue}'", + item.ItemValueId, + item.CleanValue, + newCleanValue); + item.CleanValue = newCleanValue; + itemCount++; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update CleanValue for item value {Id} ({Value})", item.ItemValueId, item.Value); + } + + processedInPartition++; + + if (processedInPartition >= Limit) + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + // Clear tracked entities to avoid memory growth across partitions + context.ChangeTracker.Clear(); + processedInPartition = 0; + } + } + + // Save any remaining changes after the loop + if (processedInPartition > 0) + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + context.ChangeTracker.Clear(); + } + + _logger.LogInformation( + "Refreshed CleanValue for {UpdatedCount} out of {TotalCount} item values in {Time}", + itemCount, + records, + sw.Elapsed); + } +} diff --git a/Jellyfin.Server/Migrations/Routines/RefreshCleanNames.cs b/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs index eca50ac100..e9eefc20dc 100644 --- a/Jellyfin.Server/Migrations/Routines/RefreshCleanNames.cs +++ b/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs @@ -6,32 +6,38 @@ using System.Threading.Tasks; using Jellyfin.Database.Implementations; using Jellyfin.Extensions; using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Migrations.Routines; /// <summary> -/// Migration to refresh CleanName values for all library items. +/// Migration to recompute the SortName of all items that have a forced sort name. /// </summary> -[JellyfinMigration("2025-10-08T12:00:00", nameof(RefreshCleanNames))] +[JellyfinMigration("2026-07-22T12:00:00", nameof(RefreshForcedSortNames))] [JellyfinMigrationBackup(JellyfinDb = true)] -public class RefreshCleanNames : IAsyncMigrationRoutine +public class RefreshForcedSortNames : IAsyncMigrationRoutine { - private readonly IStartupLogger<RefreshCleanNames> _logger; + private readonly IStartupLogger<RefreshForcedSortNames> _logger; private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IServerConfigurationManager _configurationManager; /// <summary> - /// Initializes a new instance of the <see cref="RefreshCleanNames"/> class. + /// Initializes a new instance of the <see cref="RefreshForcedSortNames"/> class. /// </summary> /// <param name="logger">The logger.</param> /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param> - public RefreshCleanNames( - IStartupLogger<RefreshCleanNames> logger, - IDbContextFactory<JellyfinDbContext> dbProvider) + /// <param name="configurationManager">The server configuration manager providing the sort rules.</param> + public RefreshForcedSortNames( + IStartupLogger<RefreshForcedSortNames> logger, + IDbContextFactory<JellyfinDbContext> dbProvider, + IServerConfigurationManager configurationManager) { _logger = logger; _dbProvider = dbProvider; + _configurationManager = configurationManager; } /// <inheritdoc /> @@ -40,16 +46,20 @@ public class RefreshCleanNames : IAsyncMigrationRoutine const int Limit = 10000; int itemCount = 0; + var configuration = _configurationManager.Configuration; + // Only the Person type disables alphanumeric sorting; everything else uses the cleaning rules. + var personType = typeof(Person).ToString(); + var sw = Stopwatch.StartNew(); using var context = _dbProvider.CreateDbContext(); - var records = context.BaseItems.Count(b => !string.IsNullOrEmpty(b.Name)); - _logger.LogInformation("Refreshing CleanName for {Count} library items", records); + var records = context.BaseItems.Count(b => !string.IsNullOrEmpty(b.ForcedSortName)); + _logger.LogInformation("Refreshing SortName for {Count} library items with a forced sort name", records); var processedInPartition = 0; await foreach (var item in context.BaseItems - .Where(b => !string.IsNullOrEmpty(b.Name)) + .Where(b => !string.IsNullOrEmpty(b.ForcedSortName)) .OrderBy(e => e.Id) .WithPartitionProgress((partition) => _logger.LogInformation("Processed: {Offset}/{Total} - Updated: {UpdatedCount} - Time: {Elapsed}", partition * Limit, records, itemCount, sw.Elapsed)) .PartitionEagerAsync(Limit, cancellationToken) @@ -58,21 +68,22 @@ public class RefreshCleanNames : IAsyncMigrationRoutine { try { - var newCleanName = string.IsNullOrWhiteSpace(item.Name) ? string.Empty : item.Name.GetCleanValue(); - if (!string.Equals(newCleanName, item.CleanName, StringComparison.Ordinal)) + var enableAlphaNumericSorting = !string.Equals(item.Type, personType, StringComparison.Ordinal); + var newSortName = BaseItem.GetSortName(item.ForcedSortName!, enableAlphaNumericSorting, configuration); + if (!string.Equals(newSortName, item.SortName, StringComparison.Ordinal)) { _logger.LogDebug( - "Updating CleanName for item {Id}: '{OldValue}' -> '{NewValue}'", + "Updating SortName for item {Id}: '{OldValue}' -> '{NewValue}'", item.Id, - item.CleanName, - newCleanName); - item.CleanName = newCleanName; + item.SortName, + newSortName); + item.SortName = newSortName; itemCount++; } } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to update CleanName for item {Id} ({Name})", item.Id, item.Name); + _logger.LogWarning(ex, "Failed to update SortName for item {Id} ({Name})", item.Id, item.Name); } processedInPartition++; @@ -94,7 +105,7 @@ public class RefreshCleanNames : IAsyncMigrationRoutine } _logger.LogInformation( - "Refreshed CleanName for {UpdatedCount} out of {TotalCount} items in {Time}", + "Refreshed SortName for {UpdatedCount} out of {TotalCount} items in {Time}", itemCount, records, sw.Elapsed); diff --git a/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs b/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs new file mode 100644 index 0000000000..16ac6cb5e5 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Restores playlist entries from playlist.xml for playlists that lost all of their children. +/// </summary> +[JellyfinMigration("2026-07-29T12:00:00", nameof(RestorePlaylistChildrenFromMetadata))] +internal class RestorePlaylistChildrenFromMetadata : IDatabaseMigrationRoutine +{ + private const string PlaylistTypeName = "MediaBrowser.Controller.Playlists.Playlist"; + private const string PlaylistFileName = "playlist.xml"; + + private readonly ILogger<RestorePlaylistChildrenFromMetadata> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IServerApplicationHost _appHost; + + public RestorePlaylistChildrenFromMetadata( + ILoggerFactory loggerFactory, + IDbContextFactory<JellyfinDbContext> dbProvider, + IServerApplicationHost appHost) + { + _logger = loggerFactory.CreateLogger<RestorePlaylistChildrenFromMetadata>(); + _dbProvider = dbProvider; + _appHost = appHost; + } + + /// <inheritdoc/> + public void Perform() + { + using var context = _dbProvider.CreateDbContext(); + + var playlists = context.BaseItems + .Where(b => b.Type == PlaylistTypeName && b.Path != null) + .Select(b => new { b.Id, b.Name, b.Path }) + .ToList(); + + if (playlists.Count == 0) + { + return; + } + + var childCountByPlaylist = context.LinkedChildren + .Where(lc => context.BaseItems.Any(b => b.Id.Equals(lc.ParentId) && b.Type == PlaylistTypeName)) + .GroupBy(lc => lc.ParentId) + .Select(g => new { ParentId = g.Key, Count = g.Count() }) + .ToDictionary(g => g.ParentId, g => g.Count); + + var pathToIdMap = context.BaseItems + .Where(b => b.Path != null) + .Select(b => new { b.Id, b.Path }) + .GroupBy(b => b.Path!) + .ToDictionary(g => g.Key, g => g.First().Id); + + var restoredPlaylists = 0; + var restoredEntries = 0; + + foreach (var playlist in playlists) + { + // Only directory-based (Jellyfin-managed) playlists keep their entries in playlist.xml. + // A playlist that is itself a file (.m3u and friends) is re-read by the library scan. + var playlistPath = _appHost.ExpandVirtualPath(playlist.Path!); + var metadataPath = Path.Combine(playlistPath, PlaylistFileName); + if (!Directory.Exists(playlistPath) || !File.Exists(metadataPath)) + { + continue; + } + + var storedPaths = ReadEntryPaths(metadataPath, playlist.Id); + if (storedPaths.Count == 0) + { + continue; + } + + var childCount = childCountByPlaylist.GetValueOrDefault(playlist.Id); + if (childCount > 0) + { + // Merging into a playlist that still has entries would resurrect anything the user + // removed while the metadata file was not rewritten, and there is no way to tell the + // two apart. Report the mismatch instead so it can be checked by hand. + if (storedPaths.Count > childCount) + { + _logger.LogWarning( + "Playlist {PlaylistName} ({PlaylistId}) holds {ChildCount} entries but {MetadataPath} lists {StoredCount}. Not restoring automatically.", + playlist.Name, + playlist.Id, + childCount, + metadataPath, + storedPaths.Count); + } + + continue; + } + + var sortOrder = 0; + foreach (var storedPath in storedPaths) + { + if (!pathToIdMap.TryGetValue(storedPath, out var childId)) + { + _logger.LogWarning( + "Cannot restore entry {EntryPath} of playlist {PlaylistName}: no library item has that path.", + storedPath, + playlist.Name); + continue; + } + + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = playlist.Id, + ChildId = childId, + ChildType = LinkedChildType.Manual, + SortOrder = sortOrder + }); + + sortOrder++; + } + + if (sortOrder > 0) + { + restoredPlaylists++; + restoredEntries += sortOrder; + _logger.LogInformation( + "Restored {Count} entries of empty playlist {PlaylistName} ({PlaylistId}) from {MetadataPath}.", + sortOrder, + playlist.Name, + playlist.Id, + metadataPath); + } + } + + if (restoredEntries > 0) + { + context.SaveChanges(); + _logger.LogInformation("Restored {EntryCount} entries across {PlaylistCount} playlists.", restoredEntries, restoredPlaylists); + } + } + + private List<string> ReadEntryPaths(string metadataPath, Guid playlistId) + { + var paths = new List<string>(); + var settings = new XmlReaderSettings + { + IgnoreComments = true, + IgnoreWhitespace = true, + IgnoreProcessingInstructions = true, + DtdProcessing = DtdProcessing.Prohibit + }; + + try + { + using var reader = XmlReader.Create(metadataPath, settings); + var inEntry = false; + while (reader.Read()) + { + if (reader.NodeType != XmlNodeType.Element) + { + continue; + } + + if (string.Equals(reader.Name, "PlaylistItem", StringComparison.Ordinal)) + { + inEntry = true; + } + else if (inEntry && string.Equals(reader.Name, "Path", StringComparison.Ordinal)) + { + inEntry = false; + var value = reader.ReadElementContentAsString(); + if (!string.IsNullOrWhiteSpace(value)) + { + paths.Add(value.Trim()); + } + } + } + } + catch (Exception ex) when (ex is XmlException or IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "Could not read playlist metadata {MetadataPath} of playlist {PlaylistId}.", metadataPath, playlistId); + } + + return paths; + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs b/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs new file mode 100644 index 0000000000..0e50ec2f47 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Recomputes the presentation unique key of every series and season so merged series are scoped to their own library. +/// </summary> +[JellyfinMigration("2026-08-21T12:00:00", nameof(RecomputeSeriesPresentationKey))] +[JellyfinMigrationBackup(JellyfinDb = true)] +internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine +{ + private readonly IStartupLogger<RecomputeSeriesPresentationKey> _logger; + private readonly ILibraryManager _libraryManager; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + + /// <summary> + /// Initializes a new instance of the <see cref="RecomputeSeriesPresentationKey"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="dbProvider">The database context factory.</param> + public RecomputeSeriesPresentationKey( + IStartupLogger<RecomputeSeriesPresentationKey> logger, + ILibraryManager libraryManager, + IDbContextFactory<JellyfinDbContext> dbProvider) + { + _logger = logger; + _libraryManager = libraryManager; + _dbProvider = dbProvider; + } + + /// <inheritdoc /> + public async Task PerformAsync(CancellationToken cancellationToken) + { + var series = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Series] + }).OfType<Series>().ToArray(); + + _logger.LogInformation("Recomputing presentation unique key for {Count} series", series.Length); + + const int ProgressInterval = 250; + var sw = Stopwatch.StartNew(); + var newSeriesKeys = new Dictionary<Guid, string>(); + var processed = 0; + var updated = 0; + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + foreach (var item in series) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (++processed % ProgressInterval == 0) + { + _logger.LogInformation("Processed {Processed}/{Total} series - Updated: {Updated} - Time: {Elapsed}", processed, series.Length, updated, sw.Elapsed); + } + + var newKey = item.CreatePresentationUniqueKey(); + newSeriesKeys[item.Id] = newKey; + + if (string.Equals(item.PresentationUniqueKey, newKey, StringComparison.Ordinal)) + { + continue; + } + + // Write only the changed column instead of re-persisting the whole item. + var id = item.Id; + await dbContext.BaseItems + .Where(e => e.Id.Equals(id)) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken) + .ConfigureAwait(false); + + // Seasons and episodes are matched to their series by SeriesPresentationUniqueKey, so + // re-point them here instead of waiting for the next scan. Scoped by SeriesId rather than + // by the old key: that key can be shared by every library holding the series, so matching + // on it would drag the other libraries' children along. + await dbContext.BaseItems + .Where(e => e.SeriesId.HasValue && e.SeriesId.Value.Equals(id)) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken) + .ConfigureAwait(false); + + updated++; + } + + var updatedSeasons = await RecomputeSeasonsAsync(dbContext, newSeriesKeys, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation( + "Recomputed presentation unique key for {Updated} of {Count} series and {UpdatedSeasons} seasons in {Elapsed}", + updated, + series.Length, + updatedSeasons, + sw.Elapsed); + } + } + + private async Task<int> RecomputeSeasonsAsync(JellyfinDbContext dbContext, Dictionary<Guid, string> newSeriesKeys, CancellationToken cancellationToken) + { + // A season's own key embeds its series' key, so it goes stale with it. + var seasons = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Season] + }).OfType<Season>().ToArray(); + + var updated = 0; + + foreach (var season in seasons) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Without an index number the season keeps the base key, which carries no series key at all. + if (!season.IndexNumber.HasValue + || !newSeriesKeys.TryGetValue(season.SeriesId, out var seriesKey)) + { + continue; + } + + // Mirrors Season.CreatePresentationUniqueKey. + var newKey = seriesKey + "-" + season.IndexNumber.Value.ToString("000", CultureInfo.InvariantCulture); + if (string.Equals(season.PresentationUniqueKey, newKey, StringComparison.Ordinal)) + { + continue; + } + + var id = season.Id; + await dbContext.BaseItems + .Where(e => e.Id.Equals(id)) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken) + .ConfigureAwait(false); + + updated++; + } + + return updated; + } +} diff --git a/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs b/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs new file mode 100644 index 0000000000..3fc2387e09 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs @@ -0,0 +1,334 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.IO; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Moves the views whose id used to be derived from their localized name onto their name independent id. +/// </summary> +[JellyfinMigration("2026-08-25T20:00:00", nameof(ConsolidateLocalizedUserViews))] +[JellyfinMigrationBackup(JellyfinDb = true)] +internal class ConsolidateLocalizedUserViews : IAsyncMigrationRoutine +{ + private readonly IStartupLogger<ConsolidateLocalizedUserViews> _logger; + private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _configurationManager; + private readonly IFileSystem _fileSystem; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + + /// <summary> + /// Initializes a new instance of the <see cref="ConsolidateLocalizedUserViews"/> class. + /// </summary> + /// <param name="logger">The startup logger.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="configurationManager">The server configuration manager.</param> + /// <param name="fileSystem">The file system.</param> + /// <param name="dbProvider">The database context factory.</param> + public ConsolidateLocalizedUserViews( + IStartupLogger<ConsolidateLocalizedUserViews> logger, + ILibraryManager libraryManager, + IServerConfigurationManager configurationManager, + IFileSystem fileSystem, + IDbContextFactory<JellyfinDbContext> dbProvider) + { + _logger = logger; + _libraryManager = libraryManager; + _configurationManager = configurationManager; + _fileSystem = fileSystem; + _dbProvider = dbProvider; + } + + /// <inheritdoc /> + public async Task PerformAsync(CancellationToken cancellationToken) + { + // The Live TV view is the one that hurts: every channel and program is parented to it, so a + // translation update or a change of UI culture used to leave them behind under a view nothing + // looks up any more. + var views = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.UserView] + }).OfType<UserView>().Where(view => view.ViewType.HasValue).ToArray(); + + if (views.Length == 0) + { + return; + } + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + foreach (var group in views.GroupBy(view => view.ViewType!.Value)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var viewType = group.Key; + var folderName = _fileSystem.GetValidFilename(viewType.ToString()); + var path = Path.Combine(_configurationManager.ApplicationPaths.InternalMetadataPath, "views", folderName); + + // Only the views created for a view type as a whole are named after it. The per user and + // per parent ones get a folder of their own, and carry no children to lose. Match on the + // folder rather than the whole path so a metadata directory that has since moved still + // lines up. + var candidates = group + .Where(view => !string.IsNullOrEmpty(view.Path) + && string.Equals(Path.GetFileName(view.Path.TrimEnd(Path.DirectorySeparatorChar)), folderName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (candidates.Length == 0) + { + continue; + } + + // Mirrors LibraryManager.GetNamedView(name, viewType, sortName). + var canonicalId = _libraryManager.GetNewItemId(path + "_namedview_" + viewType.ToString(), typeof(UserView)); + + var stale = candidates.Where(view => !view.Id.Equals(canonicalId)).ToArray(); + if (stale.Length == 0) + { + continue; + } + + await ConsolidateAsync(dbContext, viewType, path, canonicalId, candidates, stale, cancellationToken).ConfigureAwait(false); + } + } + } + + private async Task ConsolidateAsync( + JellyfinDbContext dbContext, + CollectionType viewType, + string path, + Guid canonicalId, + IReadOnlyList<UserView> candidates, + IReadOnlyList<UserView> stale, + CancellationToken cancellationToken) + { + var staleIds = stale.Select(view => view.Id).ToArray(); + Guid? newParentId = canonicalId; + var sourceId = Guid.Empty; + + if (!candidates.Any(view => view.Id.Equals(canonicalId))) + { + // Whichever of the old views the items ended up under is the one worth keeping, so give the + // canonical id a copy of it. + var source = await PickSourceAsync(dbContext, stale, staleIds, cancellationToken).ConfigureAwait(false); + sourceId = source.Id; + + _libraryManager.CreateItem( + new UserView + { + Path = path, + Id = canonicalId, + DateCreated = source.DateCreated, + DateModified = source.DateModified, + Name = source.Name, + ViewType = viewType, + ForcedSortName = source.ForcedSortName + }, + null); + } + + var reparented = await dbContext.BaseItems + .Where(e => e.ParentId.HasValue) + .WhereOneOrMany(staleIds, e => e.ParentId!.Value) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.ParentId, newParentId), cancellationToken) + .ConfigureAwait(false); + + await dbContext.BaseItems + .Where(e => e.TopParentId.HasValue) + .WhereOneOrMany(staleIds, e => e.TopParentId!.Value) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.TopParentId, newParentId), cancellationToken) + .ConfigureAwait(false); + + await MoveAncestorsAsync(dbContext, canonicalId, staleIds, cancellationToken).ConfigureAwait(false); + await MoveUserSettingsAsync(dbContext, canonicalId, sourceId, staleIds, cancellationToken).ConfigureAwait(false); + + // Nothing points at them any more, and BaseItems cascades on ParentId, so this has to come last. + await dbContext.BaseItems + .WhereOneOrMany(staleIds, e => e.Id) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + + _logger.LogInformation( + "Moved {Reparented} items and dropped {Stale} stale {ViewType} views in favour of {CanonicalId}", + reparented, + staleIds.Length, + viewType, + canonicalId); + } + + private async Task<UserView> PickSourceAsync( + JellyfinDbContext dbContext, + IReadOnlyList<UserView> stale, + IReadOnlyList<Guid> staleIds, + CancellationToken cancellationToken) + { + var childCounts = await dbContext.BaseItems + .Where(e => e.ParentId.HasValue) + .WhereOneOrMany(staleIds, e => e.ParentId!.Value) + .GroupBy(e => e.ParentId!.Value) + .Select(g => new { ParentId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(e => e.ParentId, e => e.Count, cancellationToken) + .ConfigureAwait(false); + + return stale + .OrderByDescending(view => childCounts.GetValueOrDefault(view.Id)) + .ThenBy(view => view.DateCreated) + .First(); + } + + private static async Task MoveUserSettingsAsync( + JellyfinDbContext dbContext, + Guid canonicalId, + Guid sourceId, + IReadOnlyList<Guid> staleIds, + CancellationToken cancellationToken) + { + // Everything below is keyed by the view's id, and a view holding no children still holds the + // ordering it was given and whether it was hidden. Only the view that was promoted can hand + // those over - the rest would collide on the one row per user, item and client - so the others + // are dropped instead. + var dropped = staleIds.Where(id => !id.Equals(sourceId)).ToArray(); + + if (!sourceId.Equals(Guid.Empty)) + { + var moved = new[] { sourceId }; + + await dbContext.DisplayPreferences + .WhereOneOrMany(moved, e => e.ItemId) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken) + .ConfigureAwait(false); + + await dbContext.ItemDisplayPreferences + .WhereOneOrMany(moved, e => e.ItemId) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken) + .ConfigureAwait(false); + + await dbContext.CustomItemDisplayPreferences + .WhereOneOrMany(moved, e => e.ItemId) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken) + .ConfigureAwait(false); + } + + if (dropped.Length > 0) + { + await dbContext.DisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await dbContext.ItemDisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await dbContext.CustomItemDisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + } + + var stale = staleIds.ToHashSet(); + var preferences = await dbContext.Preferences + .Where(e => e.Kind == PreferenceKind.OrderedViews || e.Kind == PreferenceKind.MyMediaExcludes) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var changed = false; + + foreach (var preference in preferences) + { + var values = preference.Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var rewritten = new List<string>(values.Length); + var seen = new HashSet<Guid>(); + var touched = false; + + foreach (var value in values) + { + // Clients write these in both the dashed and the plain form, so compare them parsed. + if (!Guid.TryParse(value, out var parsed)) + { + rewritten.Add(value); + continue; + } + + var isStale = stale.Contains(parsed); + if (isStale) + { + parsed = canonicalId; + touched = true; + } + + // The same view can be listed twice once both of its ids point at the same place. + if (!seen.Add(parsed)) + { + continue; + } + + rewritten.Add(isStale + ? parsed.ToString(value.Contains('-', StringComparison.Ordinal) ? "D" : "N", CultureInfo.InvariantCulture) + : value); + } + + if (!touched) + { + continue; + } + + preference.Value = string.Join(',', rewritten); + changed = true; + } + + if (changed) + { + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } + + private static async Task MoveAncestorsAsync( + JellyfinDbContext dbContext, + Guid canonicalId, + IReadOnlyList<Guid> staleIds, + CancellationToken cancellationToken) + { + var items = await dbContext.AncestorIds + .WhereOneOrMany(staleIds, e => e.ParentItemId) + .Select(e => e.ItemId) + .Distinct() + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + await dbContext.AncestorIds + .WhereOneOrMany(staleIds, e => e.ParentItemId) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + + if (items.Count == 0) + { + return; + } + + // The pair is the primary key, so anything already recorded against the canonical view stays put. + var existing = await dbContext.AncestorIds + .Where(e => e.ParentItemId.Equals(canonicalId)) + .Select(e => e.ItemId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + foreach (var itemId in items.Except(existing)) + { + dbContext.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = canonicalId, + Item = null!, + ParentItem = null! + }); + } + + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs b/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs deleted file mode 100644 index 1545ebdc8e..0000000000 --- a/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using Jellyfin.Data.Enums; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Playlists; - -namespace Jellyfin.Server.Migrations.Routines; - -/// <summary> -/// Remove duplicate playlist entries. -/// </summary> -#pragma warning disable CS0618 // Type or member is obsolete -[JellyfinMigration("2025-04-20T19:00:00", nameof(RemoveDuplicatePlaylistChildren), "96C156A2-7A13-4B3B-A8B8-FB80C94D20C0")] -internal class RemoveDuplicatePlaylistChildren : IMigrationRoutine -#pragma warning restore CS0618 // Type or member is obsolete -{ - private readonly ILibraryManager _libraryManager; - private readonly IPlaylistManager _playlistManager; - - public RemoveDuplicatePlaylistChildren( - ILibraryManager libraryManager, - IPlaylistManager playlistManager) - { - _libraryManager = libraryManager; - _playlistManager = playlistManager; - } - - /// <inheritdoc/> - public void Perform() - { - var playlists = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = [BaseItemKind.Playlist] - }) - .Cast<Playlist>() - .Where(p => !p.OpenAccess || !p.OwnerUserId.Equals(Guid.Empty)) - .ToArray(); - - if (playlists.Length > 0) - { - foreach (var playlist in playlists) - { - var linkedChildren = playlist.LinkedChildren; - if (linkedChildren.Length > 0) - { - var newLinkedChildren = linkedChildren - .Where(c => c.ItemId is null || c.ItemId.Value.Equals(Guid.Empty)) - .Concat(linkedChildren - .Where(c => c.ItemId.HasValue && !c.ItemId.Value.Equals(Guid.Empty)) - .DistinctBy(c => c.ItemId)) - .ToArray(); - playlist.LinkedChildren = newLinkedChildren; - playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); - _playlistManager.SavePlaylistFile(playlist); - } - } - } - } -} |
