using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Controller; using MediaBrowser.Controller.Library; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; namespace Jellyfin.Server.Migrations.Routines; /// /// Migrates LinkedChildren data from JSON Data column to the LinkedChildren table. /// [JellyfinMigration("2026-01-13T12:00:00", nameof(MigrateLinkedChildren))] [JellyfinMigrationBackup(JellyfinDb = true)] internal class MigrateLinkedChildren : IDatabaseMigrationRoutine { private readonly ILogger _logger; private readonly IDbContextFactory _dbProvider; private readonly ILibraryManager _libraryManager; private readonly IServerApplicationHost _appHost; private readonly IServerApplicationPaths _appPaths; public MigrateLinkedChildren( ILoggerFactory loggerFactory, IDbContextFactory dbProvider, ILibraryManager libraryManager, IServerApplicationHost appHost, IServerApplicationPaths appPaths) { _logger = loggerFactory.CreateLogger(); _dbProvider = dbProvider; _libraryManager = libraryManager; _appHost = appHost; _appPaths = appPaths; } /// public void Perform() { using var context = _dbProvider.CreateDbContext(); var containerTypes = new[] { "MediaBrowser.Controller.Entities.Movies.BoxSet", "MediaBrowser.Controller.Playlists.Playlist", "MediaBrowser.Controller.Entities.CollectionFolder" }; var videoTypes = new[] { "MediaBrowser.Controller.Entities.Video", "MediaBrowser.Controller.Entities.Movies.Movie", "MediaBrowser.Controller.Entities.TV.Episode" }; 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 }) .ToList(); _logger.LogInformation("Found {Count} potential items with LinkedChildren data to process.", itemsWithData.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 linkedChildrenToAdd = new List(); var processedCount = 0; const int progressLogStep = 1000; var totalItems = itemsWithData.Count; foreach (var item in itemsWithData) { if (string.IsNullOrEmpty(item.Data)) { continue; } if (processedCount > 0 && processedCount % progressLogStep == 0) { _logger.LogInformation("Processing LinkedChildren: {Processed}/{Total} items", processedCount, totalItems); } try { using var doc = JsonDocument.Parse(item.Data); var isVideo = videoTypes.Contains(item.Type); // Handle Video alternate versions if (isVideo) { ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, linkedChildrenToAdd); } // Handle LinkedChildren (for containers and other items) if (!doc.RootElement.TryGetProperty("LinkedChildren", out var linkedChildrenElement) || linkedChildrenElement.ValueKind != JsonValueKind.Array) { processedCount++; continue; } var isPlaylist = item.Type == "MediaBrowser.Controller.Playlists.Playlist"; 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()) { continue; } var childType = LinkedChildType.Manual; if (childElement.TryGetProperty("Type", out var typeProp)) { if (typeProp.ValueKind == JsonValueKind.Number) { childType = (LinkedChildType)typeProp.GetInt32(); } else if (typeProp.ValueKind == JsonValueKind.String) { var typeStr = typeProp.GetString(); if (Enum.TryParse(typeStr, out var parsedType)) { childType = parsedType; } } } linkedChildrenToAdd.Add(new LinkedChildEntity { ParentId = item.Id, ChildId = childId.Value, ChildType = childType, SortOrder = isPlaylist ? sortOrder : null }); sortOrder++; } processedCount++; } catch (JsonException ex) { _logger.LogWarning(ex, "Failed to parse JSON for item {ItemId}", item.Id); } } if (linkedChildrenToAdd.Count > 0) { _logger.LogInformation("Inserting {Count} LinkedChildren records.", linkedChildrenToAdd.Count); var existingKeys = context.LinkedChildren .Select(lc => new { lc.ParentId, lc.ChildId }) .ToHashSet(); var toInsert = linkedChildrenToAdd .Where(lc => !existingKeys.Contains(new { lc.ParentId, lc.ChildId })) .ToList(); if (toInsert.Count > 0) { // Deduplicate by composite key (ParentId, ChildId) // 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(); var childIds = toInsert.Select(lc => lc.ChildId).Distinct().ToList(); var existingChildIds = context.BaseItems .WhereOneOrMany(childIds, b => b.Id) .Select(b => b.Id) .ToHashSet(); toInsert = toInsert.Where(lc => existingChildIds.Contains(lc.ChildId)).ToList(); context.LinkedChildren.AddRange(toInsert); context.SaveChanges(); _logger.LogInformation("Successfully inserted {Count} LinkedChildren records.", toInsert.Count); } else { _logger.LogInformation("All LinkedChildren records already exist, nothing to insert."); } } else { _logger.LogInformation("No LinkedChildren data found to migrate."); } _logger.LogInformation("LinkedChildren migration completed. Processed {Count} items.", processedCount); CleanupWrongTypeAlternateVersions(context); CleanupOrphanedAlternateVersionBaseItems(context); CleanupItemsFromDeletedLibraries(context); CleanupStaleFileEntries(context); CleanupOrphanedLinkedChildren(context); } private void CleanupWrongTypeAlternateVersions(JellyfinDbContext context) { _logger.LogInformation("Cleaning up alternate version items with wrong type..."); // Find all LocalAlternateVersion relationships where the child is a generic Video // but the parent is a more specific type (like Movie). // Since IDs are computed from type + path, just updating the Type column would break ID lookups. // Instead, delete them and let the runtime recreate them with the correct type during the next library scan. var wrongTypeChildIds = context.LinkedChildren .Where(lc => lc.ChildType == LinkedChildType.LocalAlternateVersion) .Join( context.BaseItems, lc => lc.ParentId, parent => parent.Id, (lc, parent) => new { lc.ChildId, ParentType = parent.Type }) .Join( context.BaseItems, x => x.ChildId, child => child.Id, (x, child) => new { x.ChildId, x.ParentType, ChildType = child.Type }) .Where(x => x.ChildType != x.ParentType) .Select(x => x.ChildId) .Distinct() .ToList(); if (wrongTypeChildIds.Count == 0) { _logger.LogInformation("No wrong-type alternate version items found."); return; } _logger.LogInformation("Found {Count} wrong-type alternate version items to remove.", wrongTypeChildIds.Count); var itemsToDelete = wrongTypeChildIds .Select(id => _libraryManager.GetItemById(id)) .Where(item => item is not null) .ToList(); _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); _logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", itemsToDelete.Count); } private void CleanupOrphanedAlternateVersionBaseItems(JellyfinDbContext context) { _logger.LogInformation("Starting cleanup of orphaned alternate version BaseItems..."); // Find BaseItems that have OwnerId set (they belonged to another item) and are not extras, // but no LinkedChild entry references them — meaning they're orphaned alternate versions. // This happens when a version file is renamed: the old BaseItem remains in the DB // with a stale OwnerId but nothing links to it anymore. var orphanedVersionIds = context.BaseItems .Where(b => b.OwnerId.HasValue && b.ExtraType == null) .Where(b => !context.LinkedChildren.Any(lc => lc.ChildId.Equals(b.Id))) .Select(b => b.Id) .ToList(); if (orphanedVersionIds.Count == 0) { _logger.LogInformation("No orphaned alternate version BaseItems found."); return; } _logger.LogInformation("Found {Count} orphaned alternate version BaseItems to remove.", orphanedVersionIds.Count); var itemsToDelete = orphanedVersionIds .Select(id => _libraryManager.GetItemById(id)) .Where(item => item is not null) .ToList(); _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); _logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", itemsToDelete.Count); } private void CleanupItemsFromDeletedLibraries(JellyfinDbContext context) { _logger.LogInformation("Starting cleanup of items from deleted libraries..."); // Find BaseItems whose TopParentId points to a library (collection folder) that no longer exists. // This happens when a library is removed but the scan didn't fully clean up all items under it. var orphanedIds = context.BaseItems .Where(b => b.TopParentId.HasValue) .Where(b => !context.BaseItems.Any(lib => lib.Id.Equals(b.TopParentId!.Value))) .Select(b => b.Id) .ToList(); if (orphanedIds.Count == 0) { _logger.LogInformation("No items from deleted libraries found."); return; } _logger.LogInformation("Found {Count} items from deleted libraries to remove.", orphanedIds.Count); var itemsToDelete = orphanedIds .Select(id => _libraryManager.GetItemById(id)) .Where(item => item is not null) .ToList(); _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); _logger.LogInformation("Removed {Count} items from deleted libraries.", itemsToDelete.Count); } private void CleanupStaleFileEntries(JellyfinDbContext context) { _logger.LogInformation("Starting cleanup of items with missing files..."); // Get all library media locations and partition into accessible vs inaccessible. // This mirrors the scanner's safeguard: if a library root is inaccessible // (e.g. NAS offline), we skip items under it to avoid false deletions. var virtualFolders = _libraryManager.GetVirtualFolders(); var accessiblePaths = new List(); var inaccessiblePaths = new List(); foreach (var folder in virtualFolders) { foreach (var location in folder.Locations) { if (Directory.Exists(location) && Directory.EnumerateFileSystemEntries(location).Any()) { accessiblePaths.Add(location); } else { inaccessiblePaths.Add(location); _logger.LogWarning( "Library location {Path} is inaccessible or empty, skipping file existence checks for items under this path.", location); } } } var allLibraryPaths = accessiblePaths.Concat(inaccessiblePaths).ToList(); // Get all non-folder, non-virtual items with paths from the DB var itemsWithPaths = context.BaseItems .Where(b => b.Path != null && b.Path != string.Empty) .Where(b => !b.IsFolder && !b.IsVirtualItem) .Select(b => new { b.Id, b.Path }) .ToList(); var internalMetadataPath = _appPaths.InternalMetadataPath; var staleIds = new List(); foreach (var item in itemsWithPaths) { // Expand virtual path placeholders (%AppDataPath%, %MetadataPath%) to real paths var path = _appHost.ExpandVirtualPath(item.Path!); // Skip items stored under internal metadata (images, subtitles, trickplay, etc.) if (path.StartsWith(internalMetadataPath, StringComparison.OrdinalIgnoreCase)) { continue; } if (accessiblePaths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase))) { // Item is under an accessible library location — check if it still exists // Directory check covers BDMV/DVD items whose Path points to a folder if (!File.Exists(path) && !Directory.Exists(path)) { staleIds.Add(item.Id); } } else if (!allLibraryPaths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase))) { // 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); } // Otherwise: item is under an inaccessible location — skip (storage may be offline) } if (staleIds.Count == 0) { _logger.LogInformation("No stale items found."); return; } _logger.LogInformation("Found {Count} stale items to remove.", staleIds.Count); var itemsToDelete = staleIds .Select(id => _libraryManager.GetItemById(id)) .Where(item => item is not null) .ToList(); _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); _logger.LogInformation("Removed {Count} stale items.", itemsToDelete.Count); } private void CleanupOrphanedLinkedChildren(JellyfinDbContext context) { _logger.LogInformation("Starting cleanup of orphaned LinkedChildren records..."); // Find all LinkedChildren where the ChildId doesn't exist in BaseItems var orphanedLinkedChildren = context.LinkedChildren .Where(lc => !context.BaseItems.Any(b => b.Id.Equals(lc.ChildId))) .ToList(); if (orphanedLinkedChildren.Count == 0) { _logger.LogInformation("No orphaned LinkedChildren found."); return; } _logger.LogInformation("Found {Count} orphaned LinkedChildren records to remove.", orphanedLinkedChildren.Count); var orphanedByParent = context.LinkedChildren .Where(lc => !context.BaseItems.Any(b => b.Id.Equals(lc.ParentId))) .ToList(); if (orphanedByParent.Count > 0) { _logger.LogInformation("Found {Count} LinkedChildren with non-existent parent.", orphanedByParent.Count); orphanedLinkedChildren.AddRange(orphanedByParent); } // Remove all orphaned records var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.ChildId }).ToList(); context.LinkedChildren.RemoveRange(distinctOrphaned); context.SaveChanges(); _logger.LogInformation("Successfully removed {Count} orphaned LinkedChildren records.", distinctOrphaned.Count); } private void ProcessVideoAlternateVersions( JsonElement root, Guid parentId, Dictionary pathToIdMap, List linkedChildrenToAdd) { int sortOrder = 0; if (root.TryGetProperty("LocalAlternateVersions", out var localAlternateVersionsElement) && localAlternateVersionsElement.ValueKind == JsonValueKind.Array) { foreach (var pathElement in localAlternateVersionsElement.EnumerateArray()) { if (pathElement.ValueKind != JsonValueKind.String) { continue; } var path = pathElement.GetString(); if (string.IsNullOrEmpty(path)) { continue; } // Try to resolve the path to an ItemId if (pathToIdMap.TryGetValue(path, out var childId)) { linkedChildrenToAdd.Add(new LinkedChildEntity { ParentId = parentId, ChildId = childId, ChildType = LinkedChildType.LocalAlternateVersion, SortOrder = sortOrder++ }); _logger.LogDebug( "Migrating LocalAlternateVersion: Parent={ParentId}, Child={ChildId}, Path={Path}", parentId, childId, path); } else { _logger.LogWarning( "Could not resolve LocalAlternateVersion path to ItemId: {Path} for parent {ParentId}", path, parentId); } } } if (root.TryGetProperty("LinkedAlternateVersions", out var linkedAlternateVersionsElement) && linkedAlternateVersionsElement.ValueKind == JsonValueKind.Array) { 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()) { _logger.LogWarning("Could not resolve LinkedAlternateVersion child ID for parent {ParentId}", parentId); continue; } linkedChildrenToAdd.Add(new LinkedChildEntity { ParentId = parentId, ChildId = childId.Value, ChildType = LinkedChildType.LinkedAlternateVersion, SortOrder = sortOrder++ }); _logger.LogDebug( "Migrating LinkedAlternateVersion: Parent={ParentId}, Child={ChildId}", parentId, childId.Value); } } } }