diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 2acfd68c36..830c918541 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -428,6 +428,9 @@ namespace Emby.Server.Implementations.Library newPrimary.SetPrimaryVersionId(null); newPrimary.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); + // Re-route playlist/collection references from deleted primary to new primary + _itemRepository.RerouteLinkedChildren(video.Id, newPrimary.Id); + // Update remaining alternates to point to new primary foreach (var alternate in alternateVersions.Skip(1)) { @@ -436,6 +439,12 @@ namespace Emby.Server.Implementations.Library } } } + else if (item is Video alternateVideo && !string.IsNullOrEmpty(alternateVideo.PrimaryVersionId) + && Guid.TryParse(alternateVideo.PrimaryVersionId, out var primaryId)) + { + // If deleting an alternate version, re-route references to its primary + _itemRepository.RerouteLinkedChildren(alternateVideo.Id, primaryId); + } var children = item.IsFolder ? ((Folder)item).GetRecursiveChildren(false) @@ -3480,5 +3489,11 @@ namespace Emby.Server.Implementations.Library _fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path)); RemoveContentTypeOverrides(path); } + + /// + public int RerouteLinkedChildReferences(Guid fromChildId, Guid toChildId) + { + return _itemRepository.RerouteLinkedChildren(fromChildId, toChildId); + } } } diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs index 2237e36b5f..1c8b695458 100644 --- a/Jellyfin.Api/Controllers/VideosController.cs +++ b/Jellyfin.Api/Controllers/VideosController.cs @@ -222,6 +222,9 @@ public class VideosController : BaseJellyfinApiController await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + // Re-route any playlist/collection references from this item to the primary + _libraryManager.RerouteLinkedChildReferences(item.Id, primaryVersion.Id); + if (!alternateVersionsOfPrimary.Any(i => i.ItemId.HasValue && i.ItemId.Value.Equals(item.Id))) { alternateVersionsOfPrimary.Add(new LinkedChild diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index 9e6b100b66..22178e57f7 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -3843,4 +3843,43 @@ public sealed class BaseItemRepository return result; } + + /// + public IReadOnlyList GetManualLinkedParentIds(Guid childId) + { + using var context = _dbProvider.CreateDbContext(); + return context.LinkedChildren + .Where(lc => lc.ChildId == childId && lc.ChildType == DbLinkedChildType.Manual) + .Select(lc => lc.ParentId) + .Distinct() + .ToList(); + } + + /// + public int RerouteLinkedChildren(Guid fromChildId, Guid toChildId) + { + using var context = _dbProvider.CreateDbContext(); + + // Get parents that already reference toChildId (to avoid duplicates) + var parentsWithTarget = context.LinkedChildren + .Where(lc => lc.ChildId == toChildId && lc.ChildType == DbLinkedChildType.Manual) + .Select(lc => lc.ParentId) + .ToHashSet(); + + // Update references that won't create duplicates + var updated = context.LinkedChildren + .Where(lc => lc.ChildId == fromChildId + && lc.ChildType == DbLinkedChildType.Manual + && !parentsWithTarget.Contains(lc.ParentId)) + .ExecuteUpdate(s => s.SetProperty(e => e.ChildId, toChildId)); + + // Remove references that would be duplicates + context.LinkedChildren + .Where(lc => lc.ChildId == fromChildId + && lc.ChildType == DbLinkedChildType.Manual + && parentsWithTarget.Contains(lc.ParentId)) + .ExecuteDelete(); + + return updated; + } } diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index c19d15d85f..48859de04b 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -714,5 +714,14 @@ namespace MediaBrowser.Controller.Library /// The path to the virtualfolder. /// The new virtualfolder. public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo); + + /// + /// Re-routes LinkedChildren references from one child to another. + /// Used when video versions change to maintain playlist/BoxSet integrity. + /// + /// The child ID to re-route from. + /// The child ID to re-route to. + /// Number of references updated. + int RerouteLinkedChildReferences(Guid fromChildId, Guid toChildId); } } diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index f7ed39730e..504adff86c 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -210,4 +210,21 @@ public interface IItemRepository /// The user ID for access filtering. /// Dictionary mapping parent ID to child count. Dictionary GetChildCountBatch(IReadOnlyList parentIds, Guid? userId); + + /// + /// Gets parent IDs (Playlists/BoxSets) that reference the specified child with LinkedChildType.Manual. + /// + /// The child item ID. + /// List of parent IDs that reference the child. + IReadOnlyList GetManualLinkedParentIds(Guid childId); + + /// + /// Updates LinkedChildren references from one child to another, preserving SortOrder. + /// Handles duplicates: if parent already references toChildId, removes the old reference instead. + /// Used when video versions change to maintain collection integrity. + /// + /// The child ID to re-route from. + /// The child ID to re-route to. + /// Number of references updated. + int RerouteLinkedChildren(Guid fromChildId, Guid toChildId); }