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; /// /// Fixes incorrect OwnerId relationships where video/movie items are children of other video/movie items. /// These are alternate versions (4K vs 1080p) that were incorrectly linked as parent-child relationships /// by the auto-merge logic. Only legitimate extras (trailers, behind-the-scenes) should have OwnerId set. /// Also removes duplicate database entries for the same file path. /// [JellyfinMigration("2026-01-15T12:00:00", nameof(FixIncorrectOwnerIdRelationships))] [JellyfinMigrationBackup(JellyfinDb = true)] public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine { private readonly IStartupLogger _logger; private readonly IDbContextFactory _dbContextFactory; private readonly ILibraryManager _libraryManager; private readonly IItemPersistenceService _persistenceService; /// /// Initializes a new instance of the class. /// /// The startup logger. /// The database context factory. /// The library manager. /// The item persistence service. public FixIncorrectOwnerIdRelationships( IStartupLogger logger, IDbContextFactory dbContextFactory, ILibraryManager libraryManager, IItemPersistenceService persistenceService) { _logger = logger; _dbContextFactory = dbContextFactory; _libraryManager = libraryManager; _persistenceService = persistenceService; } /// public async Task PerformAsync(CancellationToken cancellationToken) { var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { // Step 1: Find and remove duplicate database entries (same Path, different IDs) await RemoveDuplicateItemsAsync(context, cancellationToken).ConfigureAwait(false); // Step 2: Clear incorrect OwnerId for video/movie items that are children of other video/movie items await ClearIncorrectOwnerIdsAsync(context, cancellationToken).ConfigureAwait(false); // Step 3: Reassign orphaned extras to correct parents await ReassignOrphanedExtrasAsync(context, cancellationToken).ConfigureAwait(false); // Step 4: Populate PrimaryVersionId for alternate version children await PopulatePrimaryVersionIdAsync(context, cancellationToken).ConfigureAwait(false); } } private async Task RemoveDuplicateItemsAsync(JellyfinDbContext context, CancellationToken cancellationToken) { // Find all paths that have duplicate entries var duplicatePaths = await context.BaseItems .Where(b => b.Path != null) .GroupBy(b => b.Path) .Where(g => g.Count() > 1) .Select(g => g.Key) .ToListAsync(cancellationToken) .ConfigureAwait(false); if (duplicatePaths.Count == 0) { _logger.LogInformation("No duplicate items found, skipping duplicate removal."); return; } _logger.LogInformation("Found {Count} paths with duplicate database entries", duplicatePaths.Count); // Collect all duplicate IDs to delete in one batch var allIdsToDelete = new List(); const int progressLogStep = 500; var processedPaths = 0; foreach (var path in duplicatePaths) { cancellationToken.ThrowIfCancellationRequested(); if (processedPaths > 0 && processedPaths % progressLogStep == 0) { _logger.LogInformation("Resolving duplicates: {Processed}/{Total} paths", processedPaths, duplicatePaths.Count); } processedPaths++; // Get all items with this path var itemsWithPath = await context.BaseItems .Where(b => b.Path == path) .Select(b => new { b.Id, b.Type, b.DateCreated, HasOwnedExtras = context.BaseItems.Any(c => c.OwnerId.HasValue && c.OwnerId.Value.Equals(b.Id)), HasDirectChildren = context.BaseItems.Any(c => c.ParentId.HasValue && c.ParentId.Value.Equals(b.Id)) }) .ToListAsync(cancellationToken) .ConfigureAwait(false); if (itemsWithPath.Count <= 1) { continue; } // Keep the item that has direct children, then owned extras, then prefer non-Folder types, then newest var itemToKeep = itemsWithPath .OrderByDescending(i => i.HasDirectChildren) .ThenByDescending(i => i.HasOwnedExtras) .ThenByDescending(i => i.Type != "MediaBrowser.Controller.Entities.Folder") .ThenByDescending(i => i.DateCreated) .First(); if (itemToKeep is null) { continue; } allIdsToDelete.AddRange(itemsWithPath.Where(i => !i.Id.Equals(itemToKeep.Id)).Select(i => i.Id)); } if (allIdsToDelete.Count > 0) { // Batch-resolve items for metadata path cleanup, then delete all at once var itemsToDelete = allIdsToDelete .Select(id => _libraryManager.GetItemById(id)) .Where(item => item is not null) .ToList(); _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!); // Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet(); var unresolvedIds = allIdsToDelete.Where(id => !deletedIds.Contains(id)).ToList(); if (unresolvedIds.Count > 0) { _persistenceService.DeleteItem(unresolvedIds); } } _logger.LogInformation("Successfully removed {Count} duplicate database entries", allIdsToDelete.Count); } private async Task ClearIncorrectOwnerIdsAsync(JellyfinDbContext context, CancellationToken cancellationToken) { // Find video/movie items with incorrect OwnerId (ExtraType is NULL or 0, pointing to another video/movie) var incorrectChildrenWithParent = await context.BaseItems .Where(b => b.OwnerId.HasValue && (b.ExtraType == null || b.ExtraType == 0) && (b.Type == "MediaBrowser.Controller.Entities.Video" || b.Type == "MediaBrowser.Controller.Entities.Movies.Movie")) .Where(b => context.BaseItems.Any(parent => parent.Id.Equals(b.OwnerId!.Value) && (parent.Type == "MediaBrowser.Controller.Entities.Video" || parent.Type == "MediaBrowser.Controller.Entities.Movies.Movie"))) .ToListAsync(cancellationToken) .ConfigureAwait(false); // Also find orphaned items (parent doesn't exist) var orphanedChildren = await context.BaseItems .Where(b => b.OwnerId.HasValue && (b.ExtraType == null || b.ExtraType == 0) && (b.Type == "MediaBrowser.Controller.Entities.Video" || b.Type == "MediaBrowser.Controller.Entities.Movies.Movie")) .Where(b => !context.BaseItems.Any(parent => parent.Id.Equals(b.OwnerId!.Value))) .ToListAsync(cancellationToken) .ConfigureAwait(false); var totalIncorrect = incorrectChildrenWithParent.Count + orphanedChildren.Count; if (totalIncorrect == 0) { _logger.LogInformation("No items with incorrect OwnerId found, skipping OwnerId cleanup."); return; } _logger.LogInformation( "Found {Count} video/movie items with incorrect OwnerId relationships ({WithParent} with parent, {Orphaned} orphaned)", totalIncorrect, incorrectChildrenWithParent.Count, orphanedChildren.Count); // Clear OwnerId for all incorrect items var allIncorrectItems = incorrectChildrenWithParent.Concat(orphanedChildren).ToList(); foreach (var item in allIncorrectItems) { item.OwnerId = null; } await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); _logger.LogInformation("Successfully cleared OwnerId for {Count} items", totalIncorrect); } private async Task ReassignOrphanedExtrasAsync(JellyfinDbContext context, CancellationToken cancellationToken) { // Find extras whose parent was deleted during duplicate removal var orphanedExtras = await context.BaseItems .Where(b => b.ExtraType != null && b.ExtraType != 0 && b.OwnerId.HasValue) .Where(b => !context.BaseItems.Any(parent => parent.Id.Equals(b.OwnerId!.Value))) .ToListAsync(cancellationToken) .ConfigureAwait(false); if (orphanedExtras.Count == 0) { _logger.LogInformation("No orphaned extras found, skipping reassignment."); return; } _logger.LogInformation("Found {Count} orphaned extras to reassign", orphanedExtras.Count); const int extraProgressLogStep = 500; // Build a lookup of directory -> first video/movie item for parent resolution var extraDirectories = orphanedExtras .Where(e => !string.IsNullOrEmpty(e.Path)) .Select(e => System.IO.Path.GetDirectoryName(e.Path)) .Where(d => !string.IsNullOrEmpty(d)) .Distinct() .ToList(); // Load all potential parent video/movies with paths in one query var videoTypes = new[] { "MediaBrowser.Controller.Entities.Video", "MediaBrowser.Controller.Entities.Movies.Movie" }; var potentialParents = await context.BaseItems .Where(b => b.Path != null && videoTypes.Contains(b.Type)) .Select(b => new { b.Id, b.Path }) .ToListAsync(cancellationToken) .ConfigureAwait(false); // Build directory -> parent ID mapping var dirToParent = new Dictionary(); foreach (var dir in extraDirectories) { var parent = potentialParents .Where(p => p.Path!.StartsWith(dir!, StringComparison.OrdinalIgnoreCase)) .OrderBy(p => p.Id) .FirstOrDefault(); if (parent is not null) { dirToParent[dir!] = parent.Id; } } var reassignedCount = 0; var processedExtras = 0; foreach (var extra in orphanedExtras) { if (processedExtras > 0 && processedExtras % extraProgressLogStep == 0) { _logger.LogInformation("Reassigning orphaned extras: {Processed}/{Total}", processedExtras, orphanedExtras.Count); } processedExtras++; if (string.IsNullOrEmpty(extra.Path)) { continue; } var extraDirectory = System.IO.Path.GetDirectoryName(extra.Path); if (!string.IsNullOrEmpty(extraDirectory) && dirToParent.TryGetValue(extraDirectory, out var parentId)) { extra.OwnerId = parentId; reassignedCount++; } else { extra.OwnerId = null; } } await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); _logger.LogInformation("Successfully reassigned {Count} orphaned extras", reassignedCount); } private async Task PopulatePrimaryVersionIdAsync(JellyfinDbContext context, CancellationToken cancellationToken) { // Find all alternate version relationships where child's PrimaryVersionId is not set // ChildType 2 = LocalAlternateVersion, ChildType 3 = LinkedAlternateVersion var alternateVersionLinks = await context.LinkedChildren .Where(lc => (lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion || lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LinkedAlternateVersion)) .Join( context.BaseItems, lc => lc.ChildId, item => item.Id, (lc, item) => new { lc.ParentId, lc.ChildId, item.PrimaryVersionId }) .Where(x => !x.PrimaryVersionId.HasValue || !x.PrimaryVersionId.Value.Equals(x.ParentId)) .ToListAsync(cancellationToken) .ConfigureAwait(false); if (alternateVersionLinks.Count == 0) { _logger.LogInformation("No alternate version items need PrimaryVersionId population, skipping."); return; } _logger.LogInformation("Found {Count} alternate version items that need PrimaryVersionId populated", alternateVersionLinks.Count); // Batch-load all child items in a single query var childIds = alternateVersionLinks.Select(l => l.ChildId).Distinct().ToList(); var childItems = await context.BaseItems .WhereOneOrMany(childIds, b => b.Id) .ToDictionaryAsync(b => b.Id, cancellationToken) .ConfigureAwait(false); var updatedCount = 0; const int linkProgressLogStep = 1000; var processedLinks = 0; foreach (var link in alternateVersionLinks) { if (processedLinks > 0 && processedLinks % linkProgressLogStep == 0) { _logger.LogInformation("Populating PrimaryVersionId: {Processed}/{Total} links", processedLinks, alternateVersionLinks.Count); } processedLinks++; if (childItems.TryGetValue(link.ChildId, out var childItem)) { childItem.PrimaryVersionId = link.ParentId; updatedCount++; } } await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); _logger.LogInformation("Successfully populated PrimaryVersionId for {Count} alternate version items", updatedCount); } }