From 71594b4a9a1fa91fbb03e6e8f79090465619bd9c Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 8 Feb 2026 17:22:52 +0100 Subject: [PATCH] Fix multiple version resolution --- .../Library/LibraryManager.cs | 79 +++++++++++++++---- .../Localization/Core/en-US.json | 2 +- .../Item/BaseItemRepository.cs | 5 +- .../Entities/Movies/Movie.cs | 52 ------------ MediaBrowser.Controller/Entities/Video.cs | 72 ++++++++--------- .../Library/ILibraryManager.cs | 16 ++++ .../SqliteDatabaseProvider.cs | 14 ++-- 7 files changed, 126 insertions(+), 114 deletions(-) diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 6bb5f87d03..df259c303f 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -704,6 +704,57 @@ namespace Emby.Server.Implementations.Library public BaseItem? ResolvePath(FileSystemMetadata fileInfo, Folder? parent = null, IDirectoryService? directoryService = null, CollectionType? collectionType = null) => ResolvePath(fileInfo, directoryService ?? new DirectoryService(_fileSystem), null, parent, collectionType); + /// + public Video? ResolveAlternateVersion(string path, Type expectedVideoType, Folder? parent, CollectionType? collectionType) + { + // Clean up any existing item saved with wrong type (e.g. Video instead of Movie). + // This happens when items were previously resolved without proper type context + // in mixed-content libraries where collectionType is null. + var expectedId = GetNewItemId(path, expectedVideoType); + if (expectedVideoType != typeof(Video)) + { + var wrongTypeId = GetNewItemId(path, typeof(Video)); + if (!wrongTypeId.Equals(expectedId) && GetItemById(wrongTypeId) is Video wrongTypeItem) + { + _logger.LogInformation( + "Removing alternate version with wrong type {WrongType}, expected {ExpectedType}: {Path}", + wrongTypeItem.GetType().Name, + expectedVideoType.Name, + path); + DeleteItem(wrongTypeItem, new DeleteOptions { DeleteFileLocation = false }); + } + } + + var resolved = ResolvePath( + _fileSystem.GetFileSystemInfo(path), + parent, + collectionType: collectionType) as Video; + + if (resolved is null) + { + return null; + } + + // Ensure the alternate version has the same concrete type as the primary video. + // ResolvePath may return a generic Video for files in mixed-content libraries + // where collectionType is null, even though the primary is a Movie/Episode/etc. + if (resolved.GetType() != expectedVideoType) + { + if (Activator.CreateInstance(expectedVideoType) is Video correctVideo) + { + correctVideo.Path = resolved.Path; + correctVideo.Name = resolved.Name; + correctVideo.VideoType = resolved.VideoType; + correctVideo.ProductionYear = resolved.ProductionYear; + correctVideo.ExtraType = resolved.ExtraType; + resolved = correctVideo; + } + } + + resolved.Id = expectedId; + return resolved; + } + private BaseItem? ResolvePath( FileSystemMetadata fileInfo, IDirectoryService directoryService, @@ -2125,14 +2176,8 @@ 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 - // 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; + // ensuring it has the same type as the primary video + var altVideo = ResolveAlternateVersion(path, videoType, parentFolder, parentCollectionType); if (altVideo is not null) { altVideo.OwnerId = video.Id; @@ -2333,14 +2378,8 @@ 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 - // 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; + // ensuring it has the same type as the primary video + var altVideo = ResolveAlternateVersion(path, videoType, parentFolder, parentCollectionType); if (altVideo is not null) { altVideo.OwnerId = video.Id; @@ -2353,6 +2392,14 @@ namespace Emby.Server.Implementations.Library _itemRepository.SaveItems(allItems, cancellationToken); + foreach (var item in allItems) + { + if (!items.Contains(item)) + { + RegisterItem(item); + } + } + if (parent is Folder folder) { folder.Children = null; diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json index c40448151c..45b1cbb6a0 100644 --- a/Emby.Server.Implementations/Localization/Core/en-US.json +++ b/Emby.Server.Implementations/Localization/Core/en-US.json @@ -130,7 +130,7 @@ "TaskOptimizeDatabaseDescription": "Compacts database and truncates free space. Running this task after scanning the library or doing other changes that imply database modifications might improve performance.", "TaskKeyframeExtractor": "Keyframe Extractor", "TaskKeyframeExtractorDescription": "Extracts keyframes from video files to create more precise HLS playlists. This task may run for a long time.", -"TaskExtractMediaSegments": "Media Segment Scan", + "TaskExtractMediaSegments": "Media Segment Scan", "TaskExtractMediaSegmentsDescription": "Extracts or obtains media segments from MediaSegment enabled plugins.", "TaskMoveTrickplayImages": "Migrate Trickplay Image Location", "TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.", diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index 99e85d946d..3cb40fa9ec 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -304,7 +304,7 @@ public sealed class BaseItemRepository } dbQuery = ApplyQueryPaging(dbQuery, filter); - dbQuery = ApplyNavigations(dbQuery, filter); + dbQuery = ApplyNavigations(dbQuery, filter).AsSplitQuery(); result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!; result.StartIndex = filter.StartIndex ?? 0; @@ -1850,7 +1850,8 @@ public sealed class BaseItemRepository .Include(e => e.LockedFields) .Include(e => e.UserData) .Include(e => e.Images) - .Include(e => e.LinkedChildEntities); + .Include(e => e.LinkedChildEntities) + .AsSingleQuery(); var item = dbQuery.FirstOrDefault(e => e.Id == id); if (item is null) diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs index 8f06d18d63..e8817a29cf 100644 --- a/MediaBrowser.Controller/Entities/Movies/Movie.cs +++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs @@ -85,58 +85,6 @@ namespace MediaBrowser.Controller.Entities.Movies return info; } - protected override async Task RefreshMetadataForVersions(MetadataRefreshOptions options, bool copyTitleMetadata, string path, CancellationToken cancellationToken) - { - var newOptions = new MetadataRefreshOptions(options) - { - SearchResult = null - }; - - 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) - { - // 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; - } - - if (movie is null) - { - return; - } - - if (movie.OwnerId.Equals(Guid.Empty)) - { - movie.OwnerId = Id; - } - - await RefreshMetadataForOwnedItem(movie, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false); - - // Create LinkedChild entry for this local alternate version - LibraryManager.UpsertLinkedChild(Id, movie.Id, LinkedChildType.LocalAlternateVersion); - } - /// public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index e06875b64c..02b3b31d3b 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -453,38 +453,45 @@ namespace MediaBrowser.Controller.Entities // The additional parts won't have additional parts themselves if (IsFileProtocol && SupportsOwnedItems) { - if (!IsStacked) + // Check if LinkedChildren are in sync before processing + var existingVersionCount = LibraryManager.GetLocalAlternateVersionIds(this).Count(); + var tasks = LocalAlternateVersions + .Select(i => RefreshMetadataForVersions(options, false, i, cancellationToken)); + + await Task.WhenAll(tasks).ConfigureAwait(false); + + if (existingVersionCount != LocalAlternateVersions.Length) { - RefreshLinkedAlternateVersions(); - - 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); - - if (existingLinkCount != LocalAlternateVersions.Length) - { - hasChanges = true; - } - } + hasChanges = true; } } return hasChanges; } - protected virtual async Task RefreshMetadataForVersions(MetadataRefreshOptions options, bool copyTitleMetadata, string path, CancellationToken cancellationToken) + private async Task RefreshMetadataForVersions(MetadataRefreshOptions options, bool copyTitleMetadata, string path, CancellationToken cancellationToken) { + // Ensure the alternate version exists with the correct type (e.g. Movie, not Video) + // before refreshing. This must happen here rather than in RefreshMetadataForOwnedVideo + // because that method is also used for stacked parts which should keep their resolved type. + var id = LibraryManager.GetNewItemId(path, GetType()); + if (LibraryManager.GetItemById(id) is not Video && FileSystem.FileExists(path)) + { + var parentFolder = GetParent() as Folder; + var collectionType = LibraryManager.GetContentType(this); + var altVideo = LibraryManager.ResolveAlternateVersion(path, GetType(), parentFolder, collectionType); + if (altVideo is not null) + { + altVideo.OwnerId = Id; + LibraryManager.CreateItem(altVideo, GetParent()); + } + } + 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); @@ -516,18 +523,21 @@ namespace MediaBrowser.Controller.Entities if (LibraryManager.GetItemById(id) is not Video video) { var parentFolder = GetParent() as Folder; - var collectionType = GetParents().OfType().FirstOrDefault()?.CollectionType; + var collectionType = LibraryManager.GetContentType(this); video = LibraryManager.ResolvePath( FileSystem.GetFileSystemInfo(path), parentFolder, collectionType: collectionType) as Video; - newOptions.ForceSave = true; - } + if (video is null) + { + return; + } - if (video is null) - { - return; + video.Id = id; + video.OwnerId = Id; + LibraryManager.CreateItem(video, parentFolder); + newOptions.ForceSave = true; } if (video.OwnerId.IsEmpty()) @@ -538,18 +548,6 @@ namespace MediaBrowser.Controller.Entities await RefreshMetadataForOwnedItem(video, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false); } - private void RefreshLinkedAlternateVersions() - { - foreach (var child in LinkedAlternateVersions) - { - // Reset the cached value - if (child.ItemId.IsNullOrEmpty()) - { - child.ItemId = null; - } - } - } - /// public override async Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken) { diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index 453d9f7c2b..e73dede1d4 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -67,6 +67,22 @@ namespace MediaBrowser.Controller.Library IDirectoryService? directoryService = null, CollectionType? collectionType = null); + /// + /// Resolves a video file as an alternate version of a primary video, ensuring the result + /// has the same concrete type as the primary (e.g. Movie instead of generic Video). + /// Also cleans up any existing item with the wrong type from a previous scan. + /// + /// The file path of the alternate version. + /// The expected concrete type (same as the primary video). + /// The parent folder. + /// The collection type of the library. + /// A correctly-typed Video, or null if resolution fails. + Video? ResolveAlternateVersion( + string path, + Type expectedVideoType, + Folder? parent, + CollectionType? collectionType); + /// /// 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 6347bac21a..7f42623c3d 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs @@ -60,11 +60,13 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider var customOptions = databaseConfiguration.CustomProviderOptions?.Options; - var sqliteConnectionBuilder = new SqliteConnectionStringBuilder(); - sqliteConnectionBuilder.DataSource = Path.Combine(_applicationPaths.DataPath, "jellyfin.db"); - sqliteConnectionBuilder.Cache = GetOption(customOptions, "cache", Enum.Parse, () => SqliteCacheMode.Default); - sqliteConnectionBuilder.Pooling = GetOption(customOptions, "pooling", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => true); - sqliteConnectionBuilder.DefaultTimeout = GetOption(customOptions, "command-timeout", int.Parse, () => 30); + var sqliteConnectionBuilder = new SqliteConnectionStringBuilder + { + DataSource = Path.Combine(_applicationPaths.DataPath, "jellyfin.db"), + Cache = GetOption(customOptions, "cache", Enum.Parse, () => SqliteCacheMode.Default), + Pooling = GetOption(customOptions, "pooling", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => true), + DefaultTimeout = GetOption(customOptions, "command-timeout", int.Parse, () => 60) + }; var connectionString = sqliteConnectionBuilder.ToString(); @@ -156,7 +158,7 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider /// public Task RestoreBackupFast(string key, CancellationToken cancellationToken) { - // ensure there are absolutly no dangling Sqlite connections. + // ensure there are absolutely no dangling Sqlite connections. SqliteConnection.ClearAllPools(); var path = Path.Combine(_applicationPaths.DataPath, "jellyfin.db"); var backupFile = Path.Combine(_applicationPaths.DataPath, BackupFolderName, $"{key}_jellyfin.db");