aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShadowghost <Ghost_of_Stone@web.de>2026-02-07 19:01:37 +0100
committerShadowghost <Ghost_of_Stone@web.de>2026-02-07 19:01:37 +0100
commit2420ece5fe47c3d990641add1648b9c220215a62 (patch)
tree1e21e237ac81b944957f376abbaa626228a8b6b7
parent00dd84035ee39b36da343e0d98d7dc83246c5f89 (diff)
Fix version resolution and scan handling
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs29
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs44
-rw-r--r--MediaBrowser.Controller/Entities/Movies/Movie.cs9
-rw-r--r--MediaBrowser.Controller/Entities/Video.cs37
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs4
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs3
6 files changed, 90 insertions, 36 deletions
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<BaseItem>(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<BaseItem>(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<MigrateLinkedChildren> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+ private readonly ILibraryManager _libraryManager;
public MigrateLinkedChildren(
ILoggerFactory loggerFactory,
- IDbContextFactory<JellyfinDbContext> dbProvider)
+ IDbContextFactory<JellyfinDbContext> dbProvider,
+ ILibraryManager libraryManager)
{
_logger = loggerFactory.CreateLogger<MigrateLinkedChildren>();
_dbProvider = dbProvider;
+ _libraryManager = libraryManager;
}
/// <inheritdoc/>
@@ -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<ICollectionFolder>().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<ICollectionFolder>().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
/// <param name="fileInfo">The file information.</param>
/// <param name="parent">The parent.</param>
/// <param name="directoryService">An instance of <see cref="IDirectoryService"/>.</param>
+ /// <param name="collectionType">The collection type of the library containing this item.</param>
/// <returns>BaseItem.</returns>
BaseItem? ResolvePath(
FileSystemMetadata fileInfo,
Folder? parent = null,
- IDirectoryService? directoryService = null);
+ IDirectoryService? directoryService = null,
+ CollectionType? collectionType = null);
/// <summary>
/// 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<int?>(customOptions, "cacheSize", e => int.Parse(e, CultureInfo.InvariantCulture)),