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