diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 29eda7d8b1..6bb5f87d03 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -407,7 +407,8 @@ namespace Emby.Server.Implementations.Library } // If deleting a primary version video, clear PrimaryVersionId from alternate versions - if (item is Video video && string.IsNullOrEmpty(video.PrimaryVersionId)) + // OwnerId check: items with OwnerId set are alternate versions or extras, not primaries + if (item is Video video && string.IsNullOrEmpty(video.PrimaryVersionId) && video.OwnerId.IsEmpty()) { var alternateVersions = GetLocalAlternateVersionIds(video) .Concat(GetLinkedAlternateVersions(video).Select(v => v.Id)) @@ -700,8 +701,8 @@ namespace Emby.Server.Implementations.Library return key.GetMD5(); } - public BaseItem? ResolvePath(FileSystemMetadata fileInfo, Folder? parent = null, IDirectoryService? directoryService = null) - => ResolvePath(fileInfo, directoryService ?? new DirectoryService(_fileSystem), null, parent); + public BaseItem? ResolvePath(FileSystemMetadata fileInfo, Folder? parent = null, IDirectoryService? directoryService = null, CollectionType? collectionType = null) + => ResolvePath(fileInfo, directoryService ?? new DirectoryService(_fileSystem), null, parent, collectionType); private BaseItem? ResolvePath( FileSystemMetadata fileInfo, @@ -2105,6 +2106,8 @@ namespace Emby.Server.Implementations.Library // 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); + var parentFolder = parent as Folder; + var parentCollectionType = parent is not null ? GetTopFolderContentType(parent) : null; foreach (var item in items) { if (item is Video video && video.LocalAlternateVersions.Length > 0) @@ -2122,7 +2125,14 @@ namespace Emby.Server.Implementations.Library 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; + // Pass parent and collectionType so the resolver creates the correct type + // (e.g. Movie instead of generic Video) + var altVideo = ResolvePath( + _fileSystem.GetFileSystemInfo(path), + new DirectoryService(_fileSystem), + null, + parentFolder, + parentCollectionType) as Video; if (altVideo is not null) { altVideo.OwnerId = video.Id; @@ -2304,6 +2314,8 @@ namespace Emby.Server.Implementations.Library // 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); + var parentFolder = parent as Folder; + var parentCollectionType = GetTopFolderContentType(parent); foreach (var item in items) { if (item is Video video && video.LocalAlternateVersions.Length > 0) @@ -2321,7 +2333,14 @@ namespace Emby.Server.Implementations.Library 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; + // Pass parent and collectionType so the resolver creates the correct type + // (e.g. Movie instead of generic Video) + var altVideo = ResolvePath( + _fileSystem.GetFileSystemInfo(path), + new DirectoryService(_fileSystem), + null, + parentFolder, + parentCollectionType) as Video; if (altVideo is not null) { altVideo.OwnerId = video.Id; diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs index d08d1a4efd..9dfb8be900 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs @@ -5,6 +5,7 @@ using System.Text.Json; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; +using MediaBrowser.Controller.Library; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; @@ -20,13 +21,16 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine { private readonly ILogger _logger; private readonly IDbContextFactory _dbProvider; + private readonly ILibraryManager _libraryManager; public MigrateLinkedChildren( ILoggerFactory loggerFactory, - IDbContextFactory dbProvider) + IDbContextFactory dbProvider, + ILibraryManager libraryManager) { _logger = loggerFactory.CreateLogger(); _dbProvider = dbProvider; + _libraryManager = libraryManager; } /// @@ -219,19 +223,21 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine _logger.LogInformation("LinkedChildren migration completed. Processed {Count} items.", processedCount); - UpdateAlternateVersionTypes(context); + CleanupWrongTypeAlternateVersions(context); CleanupOrphanedLinkedChildren(context); } - private void UpdateAlternateVersionTypes(JellyfinDbContext context) + private void CleanupWrongTypeAlternateVersions(JellyfinDbContext context) { - _logger.LogInformation("Updating alternate version item types to match their parent's type..."); + _logger.LogInformation("Cleaning up alternate version items with wrong type..."); // Find all LocalAlternateVersion relationships where the child is a generic Video - // but the parent is a more specific type (like Movie) + // but the parent is a more specific type (like Movie). + // Since IDs are computed from type + path, just updating the Type column would break ID lookups. + // Instead, delete them and let the runtime recreate them with the correct type during the next library scan. var genericVideoType = "MediaBrowser.Controller.Entities.Video"; - var alternateVersionsToUpdate = context.LinkedChildren + var wrongTypeChildIds = context.LinkedChildren .Where(lc => lc.ChildType == LinkedChildType.LocalAlternateVersion) .Join( context.BaseItems, @@ -242,30 +248,30 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine context.BaseItems, x => x.ChildId, child => child.Id, - (x, child) => new { x.ChildId, x.ParentType, ChildType = child.Type, Child = child }) + (x, child) => new { x.ChildId, x.ParentType, ChildType = child.Type }) .Where(x => x.ChildType == genericVideoType && x.ParentType != genericVideoType) + .Select(x => x.ChildId) + .Distinct() .ToList(); - if (alternateVersionsToUpdate.Count == 0) + if (wrongTypeChildIds.Count == 0) { - _logger.LogInformation("No alternate version items need type updates."); + _logger.LogInformation("No wrong-type alternate version items found."); return; } - _logger.LogInformation("Found {Count} alternate version items to update.", alternateVersionsToUpdate.Count); + _logger.LogInformation("Found {Count} wrong-type alternate version items to remove.", wrongTypeChildIds.Count); - foreach (var item in alternateVersionsToUpdate) + foreach (var childId in wrongTypeChildIds) { - item.Child.Type = item.ParentType; - _logger.LogDebug( - "Updating item {ChildId} type from {OldType} to {NewType}", - item.ChildId, - genericVideoType, - item.ParentType); + var item = _libraryManager.GetItemById(childId); + if (item is not null) + { + _libraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false }); + } } - context.SaveChanges(); - _logger.LogInformation("Successfully updated {Count} alternate version item types.", alternateVersionsToUpdate.Count); + _logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", wrongTypeChildIds.Count); } private void CleanupOrphanedLinkedChildren(JellyfinDbContext context) diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs index 5ab149a49d..8f06d18d63 100644 --- a/MediaBrowser.Controller/Entities/Movies/Movie.cs +++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs @@ -109,7 +109,14 @@ namespace MediaBrowser.Controller.Entities.Movies if (LibraryManager.GetItemById(id) is not Movie movie) { - movie = LibraryManager.ResolvePath(FileSystem.GetFileSystemInfo(path)) as Movie; + // Pass parent and collectionType so the resolver creates a Movie + // instead of a generic Video + var parentFolder = GetParent() as Folder; + var collectionType = GetParents().OfType().FirstOrDefault()?.CollectionType; + movie = LibraryManager.ResolvePath( + FileSystem.GetFileSystemInfo(path), + parentFolder, + collectionType: collectionType) as Movie; newOptions.ForceSave = true; } diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 21aa50b49d..e06875b64c 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -457,10 +457,20 @@ namespace MediaBrowser.Controller.Entities { RefreshLinkedAlternateVersions(); - var tasks = LocalAlternateVersions - .Select(i => RefreshMetadataForVersions(options, false, i, cancellationToken)); + if (LocalAlternateVersions.Length > 0) + { + // Check if LinkedChildren are in sync before processing + var existingLinkCount = LibraryManager.GetLocalAlternateVersionIds(this).Count(); + var tasks = LocalAlternateVersions + .Select(i => RefreshMetadataForVersions(options, false, i, cancellationToken)); - await Task.WhenAll(tasks).ConfigureAwait(false); + await Task.WhenAll(tasks).ConfigureAwait(false); + + if (existingLinkCount != LocalAlternateVersions.Length) + { + hasChanges = true; + } + } } } @@ -470,6 +480,15 @@ namespace MediaBrowser.Controller.Entities protected virtual async Task RefreshMetadataForVersions(MetadataRefreshOptions options, bool copyTitleMetadata, string path, CancellationToken cancellationToken) { await RefreshMetadataForOwnedVideo(options, copyTitleMetadata, path, 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 + var id = LibraryManager.GetNewItemId(path, GetType()); + if (LibraryManager.GetItemById(id) is Video video) + { + LibraryManager.UpsertLinkedChild(Id, video.Id, LinkedChildType.LocalAlternateVersion); + } } private new async Task RefreshMetadataForOwnedVideo(MetadataRefreshOptions options, bool copyTitleMetadata, string path, CancellationToken cancellationToken) @@ -496,7 +515,12 @@ namespace MediaBrowser.Controller.Entities if (LibraryManager.GetItemById(id) is not Video video) { - video = LibraryManager.ResolvePath(FileSystem.GetFileSystemInfo(path)) as Video; + var parentFolder = GetParent() as Folder; + var collectionType = GetParents().OfType().FirstOrDefault()?.CollectionType; + video = LibraryManager.ResolvePath( + FileSystem.GetFileSystemInfo(path), + parentFolder, + collectionType: collectionType) as Video; newOptions.ForceSave = true; } @@ -512,11 +536,6 @@ 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 3b3295c57d..453d9f7c2b 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -59,11 +59,13 @@ namespace MediaBrowser.Controller.Library /// The file information. /// The parent. /// An instance of . + /// The collection type of the library containing this item. /// BaseItem. BaseItem? ResolvePath( FileSystemMetadata fileInfo, Folder? parent = null, - IDirectoryService? directoryService = null); + IDirectoryService? directoryService = null, + CollectionType? collectionType = null); /// /// Resolves a set of files into a list of BaseItem. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs index da63df8e29..6347bac21a 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs @@ -77,7 +77,8 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider sqLiteOptions => sqLiteOptions.MigrationsAssembly(GetType().Assembly)) // TODO: Remove when https://github.com/dotnet/efcore/pull/35873 is merged & released .ConfigureWarnings(warnings => - warnings.Ignore(RelationalEventId.NonTransactionalMigrationOperationWarning)) + warnings.Ignore(RelationalEventId.NonTransactionalMigrationOperationWarning) + .Ignore(RelationalEventId.MultipleCollectionIncludeWarning)) .AddInterceptors(new PragmaConnectionInterceptor( _logger, GetOption(customOptions, "cacheSize", e => int.Parse(e, CultureInfo.InvariantCulture)),