aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server/Migrations
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Server/Migrations')
-rw-r--r--Jellyfin.Server/Migrations/JellyfinMigrationService.cs60
-rw-r--r--Jellyfin.Server/Migrations/MigrationOptions.cs2
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420050000_DisableTranscodingThrottling.cs (renamed from Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420060000_CreateUserLoggingConfigFile.cs (renamed from Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420070000_MigrateActivityLogDb.cs (renamed from Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420080000_RemoveDuplicateExtras.cs (renamed from Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420090000_AddDefaultPluginRepository.cs (renamed from Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420100000_MigrateUserDb.cs (renamed from Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420110000_ReaddDefaultPluginRepository.cs (renamed from Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420120000_MigrateDisplayPreferencesDb.cs (renamed from Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420130000_RemoveDownloadImagesInAdvance.cs (renamed from Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs (renamed from Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs)5
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420150000_FixPlaylistOwner.cs (renamed from Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420160000_AddDefaultCastReceivers.cs (renamed from Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420170000_UpdateDefaultPluginRepository.cs (renamed from Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420180000_FixAudioData.cs (renamed from Jellyfin.Server/Migrations/Routines/FixAudioData.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420193000_MigrateLibraryDbCompatibilityCheck.cs (renamed from Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420200000_MigrateLibraryDb.cs (renamed from Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420210000_MoveExtractedFiles.cs (renamed from Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs)10
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420230000_MoveTrickplayFiles.cs (renamed from Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420230000_RefreshInternalDateModified.cs (renamed from Jellyfin.Server/Migrations/Routines/RefreshInternalDateModified.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250421000000_MigrateKeyframeData.cs (renamed from Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs (renamed from Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250620180000_FixDates.cs (renamed from Jellyfin.Server/Migrations/Routines/FixDates.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs (renamed from Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20251009200000_CleanMusicArtist.cs (renamed from Jellyfin.Server/Migrations/Routines/CleanMusicArtist.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs (renamed from Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs)313
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260113230000_CleanupOrphanedExtras.cs (renamed from Jellyfin.Server/Migrations/Routines/CleanupOrphanedExtras.cs)81
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs (renamed from Jellyfin.Server/Migrations/Routines/FixIncorrectOwnerIdRelationships.cs)43
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260206200000_FixLibrarySubtitleDownloadLanguages.cs (renamed from Jellyfin.Server/Migrations/Routines/FixLibrarySubtitleDownloadLanguages.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs (renamed from Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs)0
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs216
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs312
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260522092304_UpdateNormalizedUsername.cs44
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs182
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260531160000_DisableLegacyAuthorization.cs32
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260610120000_RefreshCleanNamesAndValues.cs173
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs (renamed from Jellyfin.Server/Migrations/Routines/RefreshCleanNames.cs)49
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs191
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs151
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs334
-rw-r--r--Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs61
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);
- }
- }
- }
- }
-}