aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Server')
-rw-r--r--Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs1
-rw-r--r--Jellyfin.Server/Migrations/JellyfinMigrationService.cs128
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs5
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420190000_RemoveDuplicatePlaylistChildren.cs61
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs242
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs113
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs191
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs151
8 files changed, 681 insertions, 211 deletions
diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
index 2aadedfa61..6db0edb237 100644
--- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
@@ -48,6 +48,7 @@ namespace Jellyfin.Server.Extensions
c.SwaggerEndpoint($"/{baseUrl}api-docs/openapi.json", "Jellyfin API");
c.InjectStylesheet($"/{baseUrl}api-docs/swagger/custom.css");
c.RoutePrefix = "api-docs/swagger";
+ c.UseRequestInterceptor("""(req) => { req.headers['Authorization'] = `MediaBrowser Token=\"${req.headers['Authorization']}\"`; return req; }""");
})
.UseReDoc(c =>
{
diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
index a10be76e05..beafc3916f 100644
--- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
+++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
@@ -193,10 +193,15 @@ internal class JellyfinMigrationService
{
var historyRepository = dbContext.GetService<IHistoryRepository>();
var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>();
- (string Key, IInternalMigration Migration)[] migrations = [];
+ var completedMigrations = 0;
+ string? lastMigrationKey = null;
- do
- { // migrations may alter the migration state. Reevaluate the applicable migrations after every stage ran until there are no more to apply.
+ while (true)
+ {
+ // 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()))
@@ -212,73 +217,86 @@ internal class JellyfinMigrationService
}
(string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations];
- logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
- migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
+ if (pendingMigrations.Length == 0)
+ {
+ break;
+ }
- var migrationIndex = 0;
- foreach (var item in migrations)
+ if (completedMigrations == 0)
{
- // Surface generic "Running migration X of Y" progress in the always-visible startup UI header.
- SetupServer.ReportActivity(StartupActivity.Migration(++migrationIndex, migrations.Length));
- var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
- try
- {
- migrationLogger.LogInformation("Perform migration {Name}", item.Key);
- await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false);
- migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key);
- }
- catch (Exception ex)
- {
- migrationLogger.LogCritical("Error: {Error}", ex.Message);
- migrationLogger.LogError(ex, "Migration {Name} failed", item.Key);
+ 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
+ {
+ migrationLogger.LogInformation("Perform migration {Name}", item.Key);
+ await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false);
+ migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key);
+ }
+ catch (Exception ex)
+ {
+ migrationLogger.LogCritical("Error: {Error}", ex.Message);
+ migrationLogger.LogError(ex, "Migration {Name} failed", item.Key);
- if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null)
+ if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null)
+ {
+ if (_backupKey.LibraryDb is not null)
{
- if (_backupKey.LibraryDb is not null)
+ migrationLogger.LogInformation("Attempt to rollback librarydb.");
+ try
{
- migrationLogger.LogInformation("Attempt to rollback librarydb.");
- try
- {
- var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
- File.Move(_backupKey.LibraryDb, libraryDbPath, true);
- }
- catch (Exception inner)
- {
- migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb);
- }
+ var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
+ File.Move(_backupKey.LibraryDb, libraryDbPath, true);
}
-
- if (_backupKey.JellyfinDb is not null)
+ catch (Exception inner)
{
- migrationLogger.LogInformation("Attempt to rollback JellyfinDb.");
- try
- {
- await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false);
- }
- catch (Exception inner)
- {
- migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb);
- }
+ migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb);
}
+ }
- if (_backupKey.FullBackup is not null)
+ if (_backupKey.JellyfinDb is not null)
+ {
+ migrationLogger.LogInformation("Attempt to rollback JellyfinDb.");
+ try
{
- migrationLogger.LogInformation("Attempt to rollback from backup.");
- try
- {
- await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false);
- }
- catch (Exception inner)
- {
- migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path);
- }
+ await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception inner)
+ {
+ migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb);
}
}
- throw;
+ if (_backupKey.FullBackup is not null)
+ {
+ migrationLogger.LogInformation("Attempt to rollback from backup.");
+ try
+ {
+ await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false);
+ }
+ catch (Exception inner)
+ {
+ migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path);
+ }
+ }
}
+
+ throw;
}
- } while (migrations.Length != 0);
+
+ completedMigrations++;
+ }
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs b/Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs
index 0de775e03a..cbdc6efb44 100644
--- a/Jellyfin.Server/Migrations/Routines/20250420140000_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/20250420190000_RemoveDuplicatePlaylistChildren.cs b/Jellyfin.Server/Migrations/Routines/20250420190000_RemoveDuplicatePlaylistChildren.cs
deleted file mode 100644
index 1545ebdc8e..0000000000
--- a/Jellyfin.Server/Migrations/Routines/20250420190000_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);
- }
- }
- }
- }
-}
diff --git a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs
index c433c1d043..4a6e74c229 100644
--- a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs
+++ b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs
@@ -63,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);
@@ -74,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;
@@ -100,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)
@@ -110,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;
}
@@ -175,7 +160,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
ParentId = item.Id,
ChildId = childId.Value,
ChildType = childType,
- SortOrder = isPlaylist ? sortOrder : null
+ SortOrder = sortOrder
});
sortOrder++;
@@ -197,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
@@ -267,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);
@@ -418,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)
{
@@ -436,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);
}
}
@@ -443,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.");
@@ -518,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;
@@ -582,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/20260722120000_RefreshForcedSortNames.cs b/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs
new file mode 100644
index 0000000000..e9eefc20dc
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/20260722120000_RefreshForcedSortNames.cs
@@ -0,0 +1,113 @@
+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 MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to recompute the SortName of all items that have a forced sort name.
+/// </summary>
+[JellyfinMigration("2026-07-22T12:00:00", nameof(RefreshForcedSortNames))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+public class RefreshForcedSortNames : IAsyncMigrationRoutine
+{
+ private readonly IStartupLogger<RefreshForcedSortNames> _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+ private readonly IServerConfigurationManager _configurationManager;
+
+ /// <summary>
+ /// 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>
+ /// <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 />
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ 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.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.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)
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ try
+ {
+ 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 SortName for item {Id}: '{OldValue}' -> '{NewValue}'",
+ item.Id,
+ item.SortName,
+ newSortName);
+ item.SortName = newSortName;
+ itemCount++;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to update SortName 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 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;
+ }
+}