From dfa78590c2899c7e74b142ebbced4140a354aed0 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 17 Jan 2026 15:58:04 +0100 Subject: [PATCH] Add OwnerId fix migration and library options event - Add FixIncorrectOwnerIdRelationships migration routine - Add LibraryOptionsUpdatedEventArgs for library options changes --- .../FixIncorrectOwnerIdRelationships.cs | 318 ++++++++++++++++++ .../LibraryOptionsUpdatedEventArgs.cs | 31 ++ 2 files changed, 349 insertions(+) create mode 100644 Jellyfin.Server/Migrations/Routines/FixIncorrectOwnerIdRelationships.cs create mode 100644 MediaBrowser.Controller/Entities/LibraryOptionsUpdatedEventArgs.cs diff --git a/Jellyfin.Server/Migrations/Routines/FixIncorrectOwnerIdRelationships.cs b/Jellyfin.Server/Migrations/Routines/FixIncorrectOwnerIdRelationships.cs new file mode 100644 index 0000000000..0d12f065c1 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/FixIncorrectOwnerIdRelationships.cs @@ -0,0 +1,318 @@ +using System; +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 IItemRepository _itemRepository; + + /// + /// Initializes a new instance of the class. + /// + /// The startup logger. + /// The database context factory. + /// The library manager. + /// The item repository. + public FixIncorrectOwnerIdRelationships( + IStartupLogger logger, + IDbContextFactory dbContextFactory, + ILibraryManager libraryManager, + IItemRepository itemRepository) + { + _logger = logger; + _dbContextFactory = dbContextFactory; + _libraryManager = libraryManager; + _itemRepository = itemRepository; + } + + /// + 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); + + var deleteOptions = new DeleteOptions + { + DeleteFileLocation = false // Don't delete the actual file, just the database entry + }; + + var deletedCount = 0; + foreach (var path in duplicatePaths) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Get all items with this path + var itemsWithPath = await context.BaseItems + .Where(b => b.Path == path) + .Select(b => new + { + b.Id, + b.Type, + HasChildren = context.BaseItems.Any(c => c.OwnerId.HasValue && c.OwnerId.Value.Equals(b.Id) && c.ExtraType != null && c.ExtraType != 0) + }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (itemsWithPath.Count <= 1) + { + continue; + } + + // Keep the item that has legitimate children (extras), then prefer Movie type over Video type, then lowest ID + var itemWithChildren = itemsWithPath.FirstOrDefault(i => i.HasChildren); + var movieTypeItem = itemsWithPath.FirstOrDefault(i => i.Type == "MediaBrowser.Controller.Entities.Movies.Movie"); + var itemToKeep = itemWithChildren ?? movieTypeItem ?? itemsWithPath.MinBy(i => i.Id); + if (itemToKeep is null) + { + continue; + } + + // Delete all other items with this path + var itemsToDelete = itemsWithPath.Where(i => !i.Id.Equals(itemToKeep.Id)).Select(i => i.Id).ToList(); + foreach (var itemId in itemsToDelete) + { + var item = _libraryManager.GetItemById(itemId); + if (item is not null) + { + var deleted = false; + try + { + _libraryManager.DeleteItem(item, deleteOptions, notifyParentItem: false); + deletedCount++; + deleted = true; + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Failed to delete duplicate item {ItemId} at path {Path}", itemId, path); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Failed to delete duplicate item {ItemId} at path {Path}", itemId, path); + } + catch (NullReferenceException ex) + { + _logger.LogWarning(ex, "Failed to delete duplicate item {ItemId} at path {Path} via LibraryManager - falling back to direct database deletion", itemId, path); + } + + // If LibraryManager.DeleteItem failed, delete directly from database + if (!deleted) + { + try + { + _itemRepository.DeleteItem([itemId]); + deletedCount++; + _logger.LogInformation("Successfully deleted duplicate item {ItemId} at path {Path} via direct database deletion", itemId, path); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete duplicate item {ItemId} at path {Path} via direct database deletion", itemId, path); + } + } + } + } + } + + _logger.LogInformation("Successfully removed {Count} duplicate database entries", deletedCount); + } + + 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); + + var reassignedCount = 0; + foreach (var extra in orphanedExtras) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Find the parent path from the extra's path (extras are usually in same directory as parent) + if (string.IsNullOrEmpty(extra.Path)) + { + continue; + } + + var extraDirectory = System.IO.Path.GetDirectoryName(extra.Path); + if (string.IsNullOrEmpty(extraDirectory)) + { + continue; + } + + // Find potential parent in same directory + var potentialParent = await context.BaseItems + .Where(b => b.Path != null && b.Path.StartsWith(extraDirectory)) + .Where(b => b.Type == "MediaBrowser.Controller.Entities.Video" || b.Type == "MediaBrowser.Controller.Entities.Movies.Movie") + .OrderBy(b => b.Id) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (potentialParent is not null) + { + extra.OwnerId = potentialParent.Id; + reassignedCount++; + } + else + { + // Can't find a parent, clear the OwnerId + 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 == null || x.PrimaryVersionId != x.ParentId.ToString()) + .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); + + var updatedCount = 0; + foreach (var link in alternateVersionLinks) + { + cancellationToken.ThrowIfCancellationRequested(); + + var childItem = await context.BaseItems + .FirstOrDefaultAsync(b => b.Id.Equals(link.ChildId), cancellationToken) + .ConfigureAwait(false); + + if (childItem is not null) + { + childItem.PrimaryVersionId = link.ParentId.ToString(); + updatedCount++; + } + } + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Successfully populated PrimaryVersionId for {Count} alternate version items", updatedCount); + } +} diff --git a/MediaBrowser.Controller/Entities/LibraryOptionsUpdatedEventArgs.cs b/MediaBrowser.Controller/Entities/LibraryOptionsUpdatedEventArgs.cs new file mode 100644 index 0000000000..7590ad7d36 --- /dev/null +++ b/MediaBrowser.Controller/Entities/LibraryOptionsUpdatedEventArgs.cs @@ -0,0 +1,31 @@ +using System; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.Entities; + +/// +/// Event arguments for when library options are updated. +/// +public class LibraryOptionsUpdatedEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The path of the library whose options were updated. + /// The updated library options. + public LibraryOptionsUpdatedEventArgs(string libraryPath, LibraryOptions libraryOptions) + { + LibraryPath = libraryPath; + LibraryOptions = libraryOptions; + } + + /// + /// Gets the path of the library whose options were updated. + /// + public string LibraryPath { get; } + + /// + /// Gets the updated library options. + /// + public LibraryOptions LibraryOptions { get; } +}