diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 15705dee96..38aec6d491 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -1986,6 +1986,12 @@ namespace Emby.Server.Implementations.Library
return [];
}
+ ///
+ public void UpsertLinkedChild(Guid parentId, Guid childId, MediaBrowser.Controller.Entities.LinkedChildType childType)
+ {
+ _itemRepository.UpsertLinkedChild(parentId, childId, childType);
+ }
+
///
public IEnumerable Sort(IEnumerable items, User? user, IEnumerable sortBy, SortOrder sortOrder)
{
@@ -2090,9 +2096,40 @@ namespace Emby.Server.Implementations.Library
///
public void CreateItems(IReadOnlyList items, BaseItem? parent, CancellationToken cancellationToken)
{
- _itemRepository.SaveItems(items, cancellationToken);
-
+ // 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);
foreach (var item in items)
+ {
+ if (item is Video video && video.LocalAlternateVersions.Length > 0)
+ {
+ var videoType = video.GetType();
+ foreach (var path in video.LocalAlternateVersions)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ continue;
+ }
+
+ // Use the primary video's type for ID calculation to ensure consistency
+ var altId = GetNewItemId(path, videoType);
+ 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;
+ if (altVideo is not null)
+ {
+ altVideo.OwnerId = video.Id;
+ allItems.Add(altVideo);
+ }
+ }
+ }
+ }
+ }
+
+ _itemRepository.SaveItems(allItems, cancellationToken);
+
+ foreach (var item in allItems)
{
RegisterItem(item);
}
@@ -2258,7 +2295,38 @@ namespace Emby.Server.Implementations.Library
item.DateLastSaved = DateTime.UtcNow;
}
- _itemRepository.SaveItems(items, cancellationToken);
+ // 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);
+ foreach (var item in items)
+ {
+ if (item is Video video && video.LocalAlternateVersions.Length > 0)
+ {
+ var videoType = video.GetType();
+ foreach (var path in video.LocalAlternateVersions)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ continue;
+ }
+
+ // Use the primary video's type for ID calculation to ensure consistency
+ var altId = GetNewItemId(path, videoType);
+ 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;
+ if (altVideo is not null)
+ {
+ altVideo.OwnerId = video.Id;
+ allItems.Add(altVideo);
+ }
+ }
+ }
+ }
+ }
+
+ _itemRepository.SaveItems(allItems, cancellationToken);
if (parent is Folder folder)
{
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
index b154592e6e..76769c33e7 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
@@ -1599,10 +1599,35 @@ public sealed class BaseItemRepository
}
}
- // Remove orphaned alternate version links
+ // Remove orphaned alternate version links and their items
if (existingLinkedChildren.Count > 0)
{
+ // Get the child IDs of LocalAlternateVersions that are being removed
+ // These items should be deleted as they are owned by this video
+ var orphanedLocalVersionIds = existingLinkedChildren
+ .Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion)
+ .Select(e => e.ChildId)
+ .ToList();
+
context.LinkedChildren.RemoveRange(existingLinkedChildren);
+
+ // Delete the orphaned LocalAlternateVersion items themselves
+ if (orphanedLocalVersionIds.Count > 0)
+ {
+ var orphanedItems = context.BaseItems
+ .Where(e => orphanedLocalVersionIds.Contains(e.Id) && e.OwnerId == video.Id)
+ .ToList();
+
+ if (orphanedItems.Count > 0)
+ {
+ _logger.LogInformation(
+ "Deleting {Count} orphaned LocalAlternateVersion items for video {VideoName} ({VideoId})",
+ orphanedItems.Count,
+ video.Name,
+ video.Id);
+ context.BaseItems.RemoveRange(orphanedItems);
+ }
+ }
}
}
}
@@ -3942,4 +3967,31 @@ public sealed class BaseItemRepository
return updated;
}
+
+ ///
+ public void UpsertLinkedChild(Guid parentId, Guid childId, LinkedChildType childType)
+ {
+ using var context = _dbProvider.CreateDbContext();
+
+ var dbChildType = (DbLinkedChildType)childType;
+ var existingLink = context.LinkedChildren
+ .FirstOrDefault(lc => lc.ParentId == parentId && lc.ChildId == childId);
+
+ if (existingLink is null)
+ {
+ context.LinkedChildren.Add(new LinkedChildEntity
+ {
+ ParentId = parentId,
+ ChildId = childId,
+ ChildType = dbChildType,
+ SortOrder = null
+ });
+ }
+ else
+ {
+ existingLink.ChildType = dbChildType;
+ }
+
+ context.SaveChanges();
+ }
}
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs
index 15108a07b4..d08d1a4efd 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs
@@ -182,6 +182,18 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
if (toInsert.Count > 0)
{
+ // Deduplicate by composite key (ParentId, ChildId)
+ // Priority: LocalAlternateVersion > LinkedAlternateVersion > Other
+ toInsert = toInsert
+ .OrderBy(lc => lc.ChildType switch
+ {
+ LinkedChildType.LocalAlternateVersion => 0,
+ LinkedChildType.LinkedAlternateVersion => 1,
+ _ => 2
+ })
+ .DistinctBy(lc => new { lc.ParentId, lc.ChildId })
+ .ToList();
+
var childIds = toInsert.Select(lc => lc.ChildId).Distinct().ToList();
var existingChildIds = context.BaseItems
.Where(b => childIds.Contains(b.Id))
@@ -207,9 +219,55 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
_logger.LogInformation("LinkedChildren migration completed. Processed {Count} items.", processedCount);
+ UpdateAlternateVersionTypes(context);
CleanupOrphanedLinkedChildren(context);
}
+ private void UpdateAlternateVersionTypes(JellyfinDbContext context)
+ {
+ _logger.LogInformation("Updating alternate version item types to match their parent's type...");
+
+ // Find all LocalAlternateVersion relationships where the child is a generic Video
+ // but the parent is a more specific type (like Movie)
+ var genericVideoType = "MediaBrowser.Controller.Entities.Video";
+
+ var alternateVersionsToUpdate = context.LinkedChildren
+ .Where(lc => lc.ChildType == LinkedChildType.LocalAlternateVersion)
+ .Join(
+ context.BaseItems,
+ lc => lc.ParentId,
+ parent => parent.Id,
+ (lc, parent) => new { lc.ChildId, ParentType = parent.Type })
+ .Join(
+ context.BaseItems,
+ x => x.ChildId,
+ child => child.Id,
+ (x, child) => new { x.ChildId, x.ParentType, ChildType = child.Type, Child = child })
+ .Where(x => x.ChildType == genericVideoType && x.ParentType != genericVideoType)
+ .ToList();
+
+ if (alternateVersionsToUpdate.Count == 0)
+ {
+ _logger.LogInformation("No alternate version items need type updates.");
+ return;
+ }
+
+ _logger.LogInformation("Found {Count} alternate version items to update.", alternateVersionsToUpdate.Count);
+
+ foreach (var item in alternateVersionsToUpdate)
+ {
+ item.Child.Type = item.ParentType;
+ _logger.LogDebug(
+ "Updating item {ChildId} type from {OldType} to {NewType}",
+ item.ChildId,
+ genericVideoType,
+ item.ParentType);
+ }
+
+ context.SaveChanges();
+ _logger.LogInformation("Successfully updated {Count} alternate version item types.", alternateVersionsToUpdate.Count);
+ }
+
private void CleanupOrphanedLinkedChildren(JellyfinDbContext context)
{
_logger.LogInformation("Starting cleanup of orphaned LinkedChildren records...");
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index f1c1555842..3f04b1ffae 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -1430,10 +1430,15 @@ namespace MediaBrowser.Controller.Entities
});
foreach (var removedExtra in removedExtras)
{
- LibraryManager.DeleteItem(removedExtra, new DeleteOptions()
+ // Only delete items that are actual extras (have ExtraType set)
+ // Items with OwnerId but no ExtraType might be alternate versions, not extras
+ if (removedExtra.ExtraType.HasValue)
{
- DeleteFileLocation = false
- });
+ LibraryManager.DeleteItem(removedExtra, new DeleteOptions()
+ {
+ DeleteFileLocation = false
+ });
+ }
}
}
diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs
index 797f44e2d5..5ab149a49d 100644
--- a/MediaBrowser.Controller/Entities/Movies/Movie.cs
+++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs
@@ -9,8 +9,10 @@ using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Logging;
namespace MediaBrowser.Controller.Entities.Movies
{
@@ -91,6 +93,20 @@ namespace MediaBrowser.Controller.Entities.Movies
};
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)
{
movie = LibraryManager.ResolvePath(FileSystem.GetFileSystemInfo(path)) as Movie;
@@ -109,6 +125,9 @@ namespace MediaBrowser.Controller.Entities.Movies
}
await RefreshMetadataForOwnedItem(movie, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false);
+
+ // Create LinkedChild entry for this local alternate version
+ LibraryManager.UpsertLinkedChild(Id, movie.Id, LinkedChildType.LocalAlternateVersion);
}
///
diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs
index 1ddc193359..21aa50b49d 100644
--- a/MediaBrowser.Controller/Entities/Video.cs
+++ b/MediaBrowser.Controller/Entities/Video.cs
@@ -19,6 +19,7 @@ using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
+using Microsoft.Extensions.Logging;
namespace MediaBrowser.Controller.Entities
{
@@ -428,6 +429,17 @@ namespace MediaBrowser.Controller.Entities
{
var hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false);
+ // Clean up LocalAlternateVersions - remove paths that no longer exist
+ if (LocalAlternateVersions.Length > 0)
+ {
+ var validPaths = LocalAlternateVersions.Where(FileSystem.FileExists).ToArray();
+ if (validPaths.Length != LocalAlternateVersions.Length)
+ {
+ LocalAlternateVersions = validPaths;
+ hasChanges = true;
+ }
+ }
+
if (IsStacked)
{
var tasks = AdditionalParts
@@ -467,7 +479,21 @@ namespace MediaBrowser.Controller.Entities
SearchResult = null
};
- var id = LibraryManager.GetNewItemId(path, typeof(Video));
+ var id = LibraryManager.GetNewItemId(path, GetType());
+
+ // Check if the file still exists
+ if (!FileSystem.FileExists(path))
+ {
+ // File was removed - clean up any orphaned database entry
+ if (LibraryManager.GetItemById(id) is Video orphanedVideo && orphanedVideo.OwnerId.Equals(Id))
+ {
+ Logger.LogInformation("Owned video file no longer exists, removing orphaned item: {Path}", path);
+ LibraryManager.DeleteItem(orphanedVideo, new DeleteOptions { DeleteFileLocation = false });
+ }
+
+ return;
+ }
+
if (LibraryManager.GetItemById(id) is not Video video)
{
video = LibraryManager.ResolvePath(FileSystem.GetFileSystemInfo(path)) as Video;
@@ -486,6 +512,11 @@ 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 adb590ddbe..9b46dec3fe 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -20,6 +20,7 @@ using MediaBrowser.Model.IO;
using MediaBrowser.Model.Querying;
using Episode = MediaBrowser.Controller.Entities.TV.Episode;
using Genre = MediaBrowser.Controller.Entities.Genre;
+using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType;
using Person = MediaBrowser.Controller.Entities.Person;
namespace MediaBrowser.Controller.Library
@@ -229,6 +230,14 @@ namespace MediaBrowser.Controller.Library
/// Enumerable of linked Video items.
IEnumerable