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);
}