mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-03 23:36:38 +01:00
Fix multiple version handling
This commit is contained in:
@@ -1986,6 +1986,12 @@ namespace Emby.Server.Implementations.Library
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UpsertLinkedChild(Guid parentId, Guid childId, MediaBrowser.Controller.Entities.LinkedChildType childType)
|
||||
{
|
||||
_itemRepository.UpsertLinkedChild(parentId, childId, childType);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User? user, IEnumerable<ItemSortBy> sortBy, SortOrder sortOrder)
|
||||
{
|
||||
@@ -2090,9 +2096,40 @@ namespace Emby.Server.Implementations.Library
|
||||
/// <inheritdoc />
|
||||
public void CreateItems(IReadOnlyList<BaseItem> 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<BaseItem>(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<BaseItem>(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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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...");
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
/// <returns>Enumerable of linked Video items.</returns>
|
||||
IEnumerable<Video> GetLinkedAlternateVersions(Video video);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates a LinkedChild entry linking a parent to a child item.
|
||||
/// </summary>
|
||||
/// <param name="parentId">The parent item ID.</param>
|
||||
/// <param name="childId">The child item ID.</param>
|
||||
/// <param name="childType">The type of linked child relationship.</param>
|
||||
void UpsertLinkedChild(Guid parentId, Guid childId, LinkedChildType childType);
|
||||
|
||||
/// <summary>
|
||||
/// Adds the parts.
|
||||
/// </summary>
|
||||
|
||||
@@ -12,6 +12,7 @@ using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType;
|
||||
|
||||
namespace MediaBrowser.Controller.Persistence;
|
||||
|
||||
@@ -234,4 +235,13 @@ public interface IItemRepository
|
||||
/// <param name="toChildId">The child ID to re-route to.</param>
|
||||
/// <returns>Number of references updated.</returns>
|
||||
int RerouteLinkedChildren(Guid fromChildId, Guid toChildId);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates a LinkedChild entry linking a parent to a child item.
|
||||
/// If the link already exists, updates the child type.
|
||||
/// </summary>
|
||||
/// <param name="parentId">The parent item ID.</param>
|
||||
/// <param name="childId">The child item ID.</param>
|
||||
/// <param name="childType">The type of linked child relationship.</param>
|
||||
void UpsertLinkedChild(Guid parentId, Guid childId, LinkedChildType childType);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user