diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 15705dee96..38aec6d491 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1986,6 +1986,12 @@ namespace Emby.Server.Implementations.Library return []; } + /// + public void UpsertLinkedChild(Guid parentId, Guid childId, MediaBrowser.Controller.Entities.LinkedChildType childType) + { + _itemRepository.UpsertLinkedChild(parentId, childId, childType); + } + /// public IEnumerable Sort(IEnumerable items, User? user, IEnumerable sortBy, SortOrder sortOrder) { @@ -2090,9 +2096,40 @@ namespace Emby.Server.Implementations.Library /// public void CreateItems(IReadOnlyList items, BaseItem? parent, CancellationToken cancellationToken) { - _itemRepository.SaveItems(items, cancellationToken); - + // Resolve and add any local alternate version items that don't exist yet + // This ensures they exist in the database when LinkedChildren are processed + var allItems = new List(items); foreach (var item in items) + { + if (item is Video video && video.LocalAlternateVersions.Length > 0) + { + var videoType = video.GetType(); + foreach (var path in video.LocalAlternateVersions) + { + if (string.IsNullOrEmpty(path)) + { + continue; + } + + // Use the primary video's type for ID calculation to ensure consistency + var altId = GetNewItemId(path, videoType); + if (GetItemById(altId) is null && !allItems.Any(i => i.Id.Equals(altId))) + { + // Alternate version doesn't exist, resolve and create it + var altVideo = ResolvePath(_fileSystem.GetFileSystemInfo(path)) as Video; + if (altVideo is not null) + { + altVideo.OwnerId = video.Id; + allItems.Add(altVideo); + } + } + } + } + } + + _itemRepository.SaveItems(allItems, cancellationToken); + + foreach (var item in allItems) { RegisterItem(item); } @@ -2258,7 +2295,38 @@ namespace Emby.Server.Implementations.Library item.DateLastSaved = DateTime.UtcNow; } - _itemRepository.SaveItems(items, cancellationToken); + // Resolve and add any local alternate version items that don't exist yet + // This ensures they exist in the database when LinkedChildren are processed + var allItems = new List(items); + foreach (var item in items) + { + if (item is Video video && video.LocalAlternateVersions.Length > 0) + { + var videoType = video.GetType(); + foreach (var path in video.LocalAlternateVersions) + { + if (string.IsNullOrEmpty(path)) + { + continue; + } + + // Use the primary video's type for ID calculation to ensure consistency + var altId = GetNewItemId(path, videoType); + if (GetItemById(altId) is null && !allItems.Any(i => i.Id.Equals(altId))) + { + // Alternate version doesn't exist, resolve and create it + var altVideo = ResolvePath(_fileSystem.GetFileSystemInfo(path)) as Video; + if (altVideo is not null) + { + altVideo.OwnerId = video.Id; + allItems.Add(altVideo); + } + } + } + } + } + + _itemRepository.SaveItems(allItems, cancellationToken); if (parent is Folder folder) { diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index b154592e6e..76769c33e7 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -1599,10 +1599,35 @@ public sealed class BaseItemRepository } } - // Remove orphaned alternate version links + // Remove orphaned alternate version links and their items if (existingLinkedChildren.Count > 0) { + // Get the child IDs of LocalAlternateVersions that are being removed + // These items should be deleted as they are owned by this video + var orphanedLocalVersionIds = existingLinkedChildren + .Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion) + .Select(e => e.ChildId) + .ToList(); + context.LinkedChildren.RemoveRange(existingLinkedChildren); + + // Delete the orphaned LocalAlternateVersion items themselves + if (orphanedLocalVersionIds.Count > 0) + { + var orphanedItems = context.BaseItems + .Where(e => orphanedLocalVersionIds.Contains(e.Id) && e.OwnerId == video.Id) + .ToList(); + + if (orphanedItems.Count > 0) + { + _logger.LogInformation( + "Deleting {Count} orphaned LocalAlternateVersion items for video {VideoName} ({VideoId})", + orphanedItems.Count, + video.Name, + video.Id); + context.BaseItems.RemoveRange(orphanedItems); + } + } } } } @@ -3942,4 +3967,31 @@ public sealed class BaseItemRepository return updated; } + + /// + public void UpsertLinkedChild(Guid parentId, Guid childId, LinkedChildType childType) + { + using var context = _dbProvider.CreateDbContext(); + + var dbChildType = (DbLinkedChildType)childType; + var existingLink = context.LinkedChildren + .FirstOrDefault(lc => lc.ParentId == parentId && lc.ChildId == childId); + + if (existingLink is null) + { + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = parentId, + ChildId = childId, + ChildType = dbChildType, + SortOrder = null + }); + } + else + { + existingLink.ChildType = dbChildType; + } + + context.SaveChanges(); + } } diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs index 15108a07b4..d08d1a4efd 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs @@ -182,6 +182,18 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine 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 .Where(b => childIds.Contains(b.Id)) @@ -207,9 +219,55 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine _logger.LogInformation("LinkedChildren migration completed. Processed {Count} items.", processedCount); + UpdateAlternateVersionTypes(context); CleanupOrphanedLinkedChildren(context); } + private void UpdateAlternateVersionTypes(JellyfinDbContext context) + { + _logger.LogInformation("Updating alternate version item types to match their parent's type..."); + + // Find all LocalAlternateVersion relationships where the child is a generic Video + // but the parent is a more specific type (like Movie) + var genericVideoType = "MediaBrowser.Controller.Entities.Video"; + + var alternateVersionsToUpdate = 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, Child = child }) + .Where(x => x.ChildType == genericVideoType && x.ParentType != genericVideoType) + .ToList(); + + if (alternateVersionsToUpdate.Count == 0) + { + _logger.LogInformation("No alternate version items need type updates."); + return; + } + + _logger.LogInformation("Found {Count} alternate version items to update.", alternateVersionsToUpdate.Count); + + foreach (var item in alternateVersionsToUpdate) + { + item.Child.Type = item.ParentType; + _logger.LogDebug( + "Updating item {ChildId} type from {OldType} to {NewType}", + item.ChildId, + genericVideoType, + item.ParentType); + } + + context.SaveChanges(); + _logger.LogInformation("Successfully updated {Count} alternate version item types.", alternateVersionsToUpdate.Count); + } + private void CleanupOrphanedLinkedChildren(JellyfinDbContext context) { _logger.LogInformation("Starting cleanup of orphaned LinkedChildren records..."); diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index f1c1555842..3f04b1ffae 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -1430,10 +1430,15 @@ namespace MediaBrowser.Controller.Entities }); foreach (var removedExtra in removedExtras) { - LibraryManager.DeleteItem(removedExtra, new DeleteOptions() + // Only delete items that are actual extras (have ExtraType set) + // Items with OwnerId but no ExtraType might be alternate versions, not extras + if (removedExtra.ExtraType.HasValue) { - DeleteFileLocation = false - }); + LibraryManager.DeleteItem(removedExtra, new DeleteOptions() + { + DeleteFileLocation = false + }); + } } } diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs index 797f44e2d5..5ab149a49d 100644 --- a/MediaBrowser.Controller/Entities/Movies/Movie.cs +++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs @@ -9,8 +9,10 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.Entities.Movies { @@ -91,6 +93,20 @@ namespace MediaBrowser.Controller.Entities.Movies }; var id = LibraryManager.GetNewItemId(path, typeof(Movie)); + + // Check if the file still exists + if (!FileSystem.FileExists(path)) + { + // File was removed - clean up any orphaned database entry + if (LibraryManager.GetItemById(id) is Movie orphanedMovie && orphanedMovie.OwnerId.Equals(Id)) + { + Logger.LogInformation("Alternate version file no longer exists, removing orphaned item: {Path}", path); + LibraryManager.DeleteItem(orphanedMovie, new DeleteOptions { DeleteFileLocation = false }); + } + + return; + } + if (LibraryManager.GetItemById(id) is not Movie movie) { movie = LibraryManager.ResolvePath(FileSystem.GetFileSystemInfo(path)) as Movie; @@ -109,6 +125,9 @@ namespace MediaBrowser.Controller.Entities.Movies } await RefreshMetadataForOwnedItem(movie, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false); + + // Create LinkedChild entry for this local alternate version + LibraryManager.UpsertLinkedChild(Id, movie.Id, LinkedChildType.LocalAlternateVersion); } /// diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 1ddc193359..21aa50b49d 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -19,6 +19,7 @@ using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.Entities { @@ -428,6 +429,17 @@ namespace MediaBrowser.Controller.Entities { var hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false); + // Clean up LocalAlternateVersions - remove paths that no longer exist + if (LocalAlternateVersions.Length > 0) + { + var validPaths = LocalAlternateVersions.Where(FileSystem.FileExists).ToArray(); + if (validPaths.Length != LocalAlternateVersions.Length) + { + LocalAlternateVersions = validPaths; + hasChanges = true; + } + } + if (IsStacked) { var tasks = AdditionalParts @@ -467,7 +479,21 @@ namespace MediaBrowser.Controller.Entities SearchResult = null }; - var id = LibraryManager.GetNewItemId(path, typeof(Video)); + var id = LibraryManager.GetNewItemId(path, GetType()); + + // Check if the file still exists + if (!FileSystem.FileExists(path)) + { + // File was removed - clean up any orphaned database entry + if (LibraryManager.GetItemById(id) is Video orphanedVideo && orphanedVideo.OwnerId.Equals(Id)) + { + Logger.LogInformation("Owned video file no longer exists, removing orphaned item: {Path}", path); + LibraryManager.DeleteItem(orphanedVideo, new DeleteOptions { DeleteFileLocation = false }); + } + + return; + } + if (LibraryManager.GetItemById(id) is not Video video) { video = LibraryManager.ResolvePath(FileSystem.GetFileSystemInfo(path)) as Video; @@ -486,6 +512,11 @@ namespace MediaBrowser.Controller.Entities } await RefreshMetadataForOwnedItem(video, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false); + + // Create LinkedChild entry for this local alternate version + // This ensures the relationship exists in the database even if the alternate version + // was created after the primary video was first saved + LibraryManager.UpsertLinkedChild(Id, video.Id, LinkedChildType.LocalAlternateVersion); } private void RefreshLinkedAlternateVersions() diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index adb590ddbe..9b46dec3fe 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -20,6 +20,7 @@ using MediaBrowser.Model.IO; using MediaBrowser.Model.Querying; using Episode = MediaBrowser.Controller.Entities.TV.Episode; using Genre = MediaBrowser.Controller.Entities.Genre; +using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType; using Person = MediaBrowser.Controller.Entities.Person; namespace MediaBrowser.Controller.Library @@ -229,6 +230,14 @@ namespace MediaBrowser.Controller.Library /// Enumerable of linked Video items. IEnumerable