Fix version resolution and scan handling

This commit is contained in:
Shadowghost
2026-02-07 19:01:37 +01:00
parent 00dd84035e
commit 2420ece5fe
6 changed files with 90 additions and 36 deletions

View File

@@ -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;

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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()

View File

@@ -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.

View File

@@ -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)),