diff options
| author | Shadowghost <Ghost_of_Stone@web.de> | 2026-08-01 21:41:16 +0200 |
|---|---|---|
| committer | Shadowghost <Ghost_of_Stone@web.de> | 2026-08-01 21:41:16 +0200 |
| commit | 705368ee495cfb8967d3f1651318fbdb85ad89e6 (patch) | |
| tree | d0c99252541c73ddeaddb036079a00808ab9a955 /Jellyfin.Server.Implementations | |
| parent | cbc2c7c32345f670d928f7840e5fd7422241845d (diff) | |
| parent | e16c8a07bd8e21973a88231eeed83c9699fd58b2 (diff) | |
Merge remote-tracking branch 'upstream/master' into fix-byname-queries
# Conflicts:
# src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
Diffstat (limited to 'Jellyfin.Server.Implementations')
8 files changed, 213 insertions, 248 deletions
diff --git a/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs index d70ac672f2..0f166fc6e0 100644 --- a/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs +++ b/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs @@ -40,6 +40,19 @@ public static class ExpressionExtensions } /// <summary> + /// Negates a predicate. + /// </summary> + /// <typeparam name="T">The predicate parameter type.</typeparam> + /// <param name="predicate">The predicate expression to negate.</param> + /// <returns>A new expression representing the negation of the input predicate.</returns> + public static Expression<Func<T, bool>> Not<T>(this Expression<Func<T, bool>> predicate) + { + ArgumentNullException.ThrowIfNull(predicate); + + return Expression.Lambda<Func<T, bool>>(Expression.Not(predicate.Body), predicate.Parameters); + } + + /// <summary> /// Combines two predicates into a single predicate using a logical AND operation. /// </summary> /// <typeparam name="T">The predicate parameter type.</typeparam> diff --git a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs index c64e6ac068..958d11e21e 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs @@ -183,7 +183,7 @@ public static class BaseItemMapper if (dto is Folder folder) { folder.DateLastMediaAdded = entity.DateLastMediaAdded ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); - if (entity.LinkedChildEntities is not null && entity.LinkedChildEntities.Count > 0) + if (entity.LinkedChildEntities is not null) { folder.LinkedChildren = entity.LinkedChildEntities .OrderBy(e => e.SortOrder) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index 96a23d2d12..5aa2d7c46b 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -621,62 +621,31 @@ public sealed partial class BaseItemRepository } /// <inheritdoc /> - public IQueryable<Guid> GetFullyPlayedFolderIdsQuery(JellyfinDbContext context, IQueryable<Guid> folderIds, User user) + public IQueryable<BaseItemEntity> GetAccessFilteredLeafItemsQuery(JellyfinDbContext context, User user, bool includeOwnedItems = false) { ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(folderIds); ArgumentNullException.ThrowIfNull(user); - var filter = new InternalItemsQuery(user); - var userId = user.Id; - var leafItems = context.BaseItems .AsNoTracking() - .Where(b => !b.IsFolder && !b.IsVirtualItem); - leafItems = ApplyAccessFiltering(context, leafItems, filter); - - var playedLeafItems = leafItems - .Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) }); - - var ancestorLeaves = context.AncestorIds - .Where(a => folderIds.Contains(a.ParentItemId)) - .Join( - playedLeafItems, - a => a.ItemId, - b => b.Id, - (a, b) => new { FolderId = a.ParentItemId, b.Id, b.Played }); + .Where(e => !e.IsFolder && !e.IsVirtualItem); - var linkedLeaves = context.LinkedChildren - .Where(lc => folderIds.Contains(lc.ParentId)) - .Join( - playedLeafItems, - lc => lc.ChildId, - b => b.Id, - (lc, b) => new { FolderId = lc.ParentId, b.Id, b.Played }); + return ApplyAccessFiltering(context, leafItems, new InternalItemsQuery(user) { IncludeOwnedItems = includeOwnedItems }); + } - var linkedFolderLeaves = context.LinkedChildren - .Where(lc => folderIds.Contains(lc.ParentId)) - .Join( - context.BaseItems.Where(b => b.IsFolder), - lc => lc.ChildId, - b => b.Id, - (lc, b) => new { lc.ParentId, FolderChildId = b.Id }) - .Join( - context.AncestorIds, - x => x.FolderChildId, - a => a.ParentItemId, - (x, a) => new { x.ParentId, DescendantId = a.ItemId }) - .Join( - playedLeafItems, - x => x.DescendantId, - b => b.Id, - (x, b) => new { FolderId = x.ParentId, b.Id, b.Played }); - - return ancestorLeaves - .Union(linkedLeaves) - .Union(linkedFolderLeaves) - .GroupBy(x => x.FolderId) - .Where(g => g.Select(x => x.Id).Distinct().Count() == g.Where(x => x.Played).Select(x => x.Id).Distinct().Count()) - .Select(g => g.Key); + /// <inheritdoc /> + public Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> descendants) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(descendants); + + // Descendants are reachable through the ancestor chain and - for BoxSets and Playlists - as + // linked children, which can themselves be folders contributing their own descendants. + // Every step is a correlated index seek, so only the rows the outer query keeps are visited + // and a folder is left as soon as its first matching descendant is found. + return e => context.AncestorIds.Any(a => a.ParentItemId == e.Id && descendants.Any(d => d.Id == a.ItemId)) + || context.LinkedChildren.Any(lc => lc.ParentId == e.Id + && (descendants.Any(d => d.Id == lc.ChildId) + || context.AncestorIds.Any(a => a.ParentItemId == lc.ChildId && descendants.Any(d => d.Id == a.ItemId)))); } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index fb9bbf0d47..1694e89179 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -31,6 +31,10 @@ public sealed partial class BaseItemRepository private static readonly string TmdbProviderName = MetadataProvider.Tmdb.ToString().ToLowerInvariant(); private static readonly string TvdbProviderName = MetadataProvider.Tvdb.ToString().ToLowerInvariant(); + // A fresh expression per access: EF rejects a query tree that reuses one lambda parameter + // instance across several lambdas, and this filter is combined into a tree more than once. + private static Expression<Func<BaseItemEntity, bool>> IsFolderFilter => e => e.IsFolder; + /// <inheritdoc /> public IQueryable<BaseItemEntity> TranslateQuery( IQueryable<BaseItemEntity> baseQuery, @@ -466,97 +470,45 @@ public sealed partial class BaseItemRepository if (filter.IsPlayed.HasValue) { - var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series); - var hasBoxSet = filter.IncludeItemTypes.Contains(BaseItemKind.BoxSet); + var userId = filter.User!.Id; - if (hasSeries || hasBoxSet) - { - var userId = filter.User!.Id; - var isPlayed = filter.IsPlayed.Value; - var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; - var boxSetTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.BoxSet]; - - // Series: played = at least one episode AND all episodes played; unplayed = otherwise. - IQueryable<Guid> playedSeriesIds = hasSeries - ? context.BaseItems - .AsNoTracking() - .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue) - .GroupBy(e => e.SeriesId!.Value) - .Where(g => !g.Any(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))) - .Select(g => g.Key) - : Enumerable.Empty<Guid>().AsQueryable(); - - // BoxSet: played = all children played. - IQueryable<Guid> playedBoxSetIds = hasBoxSet - ? GetFullyPlayedFolderIdsQuery( - context, - baseQuery.Where(e => e.Type == boxSetTypeName).Select(e => e.Id), - filter.User!) - : Enumerable.Empty<Guid>().AsQueryable(); - - // Non-folder items: check UserData directly - var playedItemIds = context.UserData - .Where(ud => ud.UserId == userId && ud.Played) - .Select(ud => ud.ItemId); - - if (isPlayed) - { - baseQuery = baseQuery.Where(e => - (e.Type == seriesTypeName && playedSeriesIds.Contains(e.Id)) - || (e.Type == boxSetTypeName && playedBoxSetIds.Contains(e.Id)) - || (e.Type != seriesTypeName && e.Type != boxSetTypeName && playedItemIds.Contains(e.Id))); - } - else - { - baseQuery = baseQuery.Where(e => - (e.Type == seriesTypeName && !playedSeriesIds.Contains(e.Id)) - || (e.Type == boxSetTypeName && !playedBoxSetIds.Contains(e.Id)) - || (e.Type != seriesTypeName && e.Type != boxSetTypeName && !playedItemIds.Contains(e.Id))); - } - } - else - { - var playedItemIds = context.UserData - .Where(ud => ud.UserId == filter.User!.Id && ud.Played) - .Select(ud => ud.ItemId); - var isPlayedItem = filter.IsPlayed.Value; - baseQuery = baseQuery.Where(e => playedItemIds.Contains(e.Id) == isPlayedItem); - } + // Leaf items carry their own played state. + var playedItemIds = context.UserData + .Where(ud => ud.UserId == userId && ud.Played) + .Select(ud => ud.ItemId); + + // Folders (Series, Seasons, BoxSets, albums, ...) have none and count as played once no + // descendant is left unplayed, matching what the DTO reports for them. This has to key off + // the item itself rather than off the requested item types: tag and collection listings mix + // folders and leaf items in a single query. + var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!) + .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + + var isPlayedFilter = IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not()) + .Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id))); + + baseQuery = baseQuery.Where(filter.IsPlayed.Value ? isPlayedFilter : isPlayedFilter.Not()); } if (filter.IsResumable.HasValue) { - var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series); var userId = filter.User!.Id; var isResumable = filter.IsResumable.Value; - var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; // In-progress user data rows; alternate versions track their own progress. var inProgress = context.UserData .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0); - IQueryable<Guid>? resumableSeriesIds = null; - if (hasSeries) - { - // Aggregate per series in a single GROUP BY pass, instead of three full scans. - var seriesEpisodeStats = context.BaseItems - .AsNoTracking() - .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue) - .GroupBy(e => e.SeriesId!.Value) - .Select(g => new - { - SeriesId = g.Key, - HasInProgress = g.Any(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)), - HasPlayed = g.Any(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)), - HasUnplayed = g.Any(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)) - }); - - // A series is resumable if it has an in-progress episode, - // or if it has both played and unplayed episodes (partially watched). - resumableSeriesIds = seriesEpisodeStats - .Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed)) - .Select(s => s.SeriesId); - } + // Folders are resumable when a descendant is in progress, or when they hold both played and + // unplayed descendants (partially watched). Alternate versions keep their own progress, so + // they count towards the in-progress check but not towards the played/unplayed one. + var leafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!); + var inProgressLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!, includeOwnedItems: true) + .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)); + + var folderResumableFilter = BuildHasDescendantFilter(context, inProgressLeafItems) + .Or(BuildHasDescendantFilter(context, leafItems.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played))) + .And(BuildHasDescendantFilter(context, leafItems.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))))); if (isResumable) { @@ -564,18 +516,15 @@ public sealed partial class BaseItemRepository // Match each version on its own progress rather than coalescing onto the primary. var inProgressIds = inProgress.Select(ud => ud.ItemId); - baseQuery = hasSeries - ? baseQuery.Where(e => - (e.Type == seriesTypeName && resumableSeriesIds!.Contains(e.Id)) - || (e.Type != seriesTypeName && inProgressIds.Contains(e.Id))) - : baseQuery.Where(e => inProgressIds.Contains(e.Id)); + baseQuery = baseQuery.Where(IsFolderFilter.And(folderResumableFilter) + .Or(IsFolderFilter.Not().And(e => inProgressIds.Contains(e.Id)))); // When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker. // Only in-progress siblings can eliminate a candidate: a version without progress has a NULL max LastPlayedDate, // which is never greater and never ties. Restricting the sibling scan to the in-progress set keeps this bounded by // the user's Continue Watching count instead of forcing a full BaseItems scan (COALESCE keys are non-indexable) per row. // Items in no version group at all have no sibling that could eliminate them, so short-circuit the scan for those. - baseQuery = baseQuery.Where(e => e.Type == seriesTypeName + baseQuery = baseQuery.Where(e => e.IsFolder || (e.PrimaryVersionId == null && !context.BaseItems.Any(a => a.PrimaryVersionId == e.Id)) || !context.BaseItems .Where(s => s.Id != e.Id @@ -594,11 +543,8 @@ public sealed partial class BaseItemRepository var resumableMovieIds = inProgress .Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id); - baseQuery = hasSeries - ? baseQuery.Where(e => - (e.Type == seriesTypeName && !resumableSeriesIds!.Contains(e.Id)) - || (e.Type != seriesTypeName && !resumableMovieIds.Contains(e.Id))) - : baseQuery.Where(e => !resumableMovieIds.Contains(e.Id)); + baseQuery = baseQuery.Where(IsFolderFilter.And(folderResumableFilter.Not()) + .Or(IsFolderFilter.Not().And(e => !resumableMovieIds.Contains(e.Id)))); } } diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs index 604db9f839..4aa65769fd 100644 --- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -141,32 +141,32 @@ public class ItemCountService : IItemCountService switch (kind) { case BaseItemKind.Person: - baseQuery = context.PeopleBaseItemMap + baseQuery = ItemsById(context, context.PeopleBaseItemMap .AsNoTracking() .Where(m => m.People.Name == item.Name) - .Select(m => m.Item); + .Select(m => m.ItemId)); break; case BaseItemKind.MusicArtist: - baseQuery = context.ItemValuesMap + baseQuery = ItemsById(context, context.ItemValuesMap .AsNoTracking() .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName && (ivm.ItemValue.Type == ItemValueType.Artist || ivm.ItemValue.Type == ItemValueType.AlbumArtist)) - .Select(ivm => ivm.Item); + .Select(ivm => ivm.ItemId)); break; case BaseItemKind.Genre: case BaseItemKind.MusicGenre: - baseQuery = context.ItemValuesMap + baseQuery = ItemsById(context, context.ItemValuesMap .AsNoTracking() .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName && ivm.ItemValue.Type == ItemValueType.Genre) - .Select(ivm => ivm.Item); + .Select(ivm => ivm.ItemId)); break; case BaseItemKind.Studio: - baseQuery = context.ItemValuesMap + baseQuery = ItemsById(context, context.ItemValuesMap .AsNoTracking() .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName && ivm.ItemValue.Type == ItemValueType.Studios) - .Select(ivm => ivm.Item); + .Select(ivm => ivm.ItemId)); break; case BaseItemKind.Year: if (int.TryParse(item.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)) @@ -254,6 +254,9 @@ public class ItemCountService : IItemCountService return result; } + private static IQueryable<BaseItemEntity> ItemsById(JellyfinDbContext context, IQueryable<Guid> itemIds) + => context.BaseItems.AsNoTracking().Where(e => itemIds.Contains(e.Id)); + /// <inheritdoc/> public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId) { diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs index b10f7c527e..827c766449 100644 --- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs @@ -428,106 +428,144 @@ public class ItemPersistenceService : IItemPersistenceService foreach (var item in tuples) { - if (item.Item is Folder folder) + // A container that was never hydrated cannot be used to rewrite its links: its empty + // array means "unknown", so clearing the stored rows would silently empty the item. + if (item.Item is Folder { LinkedChildrenLoaded: false }) { - var existingLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(item.Item.Id)?.ToList() ?? new List<LinkedChildEntity>(); - if (folder.LinkedChildren.Length > 0) + continue; + } + + if (item.Item is Folder or Video + && allLinkedChildrenByParent.TryGetValue(item.Item.Id, out var existingLinks) + && existingLinks.Count > 0) + { + // A video only owns its alternate version links; any other link on that parent is + // written by the folder branch below and must survive. + var staleLinks = item.Item is Folder + ? existingLinks + : existingLinks + .Where(e => e.ChildType is DbLinkedChildType.LocalAlternateVersion or DbLinkedChildType.LinkedAlternateVersion) + .ToList(); + + if (staleLinks.Count > 0) { + context.LinkedChildren.RemoveRange(staleLinks); + } + } + } + + context.SaveChanges(); + + // A LinkedChild's ItemId is only a cache. + var cachedChildIds = tuples + .Select(t => t.Item) + .OfType<Folder>() + .Where(f => f.LinkedChildrenLoaded) + .SelectMany(f => f.LinkedChildren) + .Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty()) + .Select(lc => lc.ItemId!.Value) + .Distinct() + .ToList(); + + var knownChildIds = cachedChildIds.Count > 0 + ? context.BaseItems + .WhereOneOrMany(cachedChildIds, e => e.Id) + .Select(e => e.Id) + .ToHashSet() + : []; + + foreach (var item in tuples) + { + if (item.Item is Folder { LinkedChildrenLoaded: true } folder && folder.LinkedChildren.Length > 0) + { #pragma warning disable CS0618 // Type or member is obsolete - legacy path resolution for old data - var pathsToResolve = folder.LinkedChildren - .Where(lc => (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty()) && !string.IsNullOrEmpty(lc.Path)) - .Select(lc => lc.Path) - .Distinct() - .ToList(); + var pathsToResolve = folder.LinkedChildren + .Where(lc => !string.IsNullOrEmpty(lc.Path) + && (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty() || !knownChildIds.Contains(lc.ItemId.Value))) + .Select(lc => lc.Path) + .Distinct() + .ToList(); - var pathToIdMap = pathsToResolve.Count > 0 - ? context.BaseItems - .Where(e => e.Path != null && pathsToResolve.Contains(e.Path)) - .Select(e => new { e.Path, e.Id }) - .GroupBy(e => e.Path!) - .ToDictionary(g => g.Key, g => g.First().Id) - : []; + var pathToIdMap = pathsToResolve.Count > 0 + ? context.BaseItems + .Where(e => e.Path != null && pathsToResolve.Contains(e.Path)) + .Select(e => new { e.Path, e.Id }) + .GroupBy(e => e.Path!) + .ToDictionary(g => g.Key, g => g.First().Id) + : []; - var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>(); - foreach (var linkedChild in folder.LinkedChildren) + var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>(); + foreach (var linkedChild in folder.LinkedChildren) + { + var childItemId = linkedChild.ItemId; + if (!childItemId.HasValue || childItemId.Value.IsEmpty() || !knownChildIds.Contains(childItemId.Value)) { - var childItemId = linkedChild.ItemId; - if (!childItemId.HasValue || childItemId.Value.IsEmpty()) + if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId)) { - if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId)) - { - childItemId = resolvedId; - } + childItemId = resolvedId; } -#pragma warning restore CS0618 - - if (childItemId.HasValue && !childItemId.Value.IsEmpty()) + else if (Guid.TryParse(linkedChild.LibraryItemId, out var libraryItemId) && !libraryItemId.IsEmpty()) { - resolvedChildren.Add((linkedChild, childItemId.Value)); + childItemId = libraryItemId; } } +#pragma warning restore CS0618 + if (childItemId.HasValue && !childItemId.Value.IsEmpty()) + { + resolvedChildren.Add((linkedChild, childItemId.Value)); + } + } + + // Playlists may legitimately contain the same item multiple times (e.g. a song repeated + // in an .m3u file). Every other container type keeps a single entry per child. + var isPlaylist = folder is Playlist; + if (!isPlaylist) + { resolvedChildren = resolvedChildren .GroupBy(c => c.ChildId) .Select(g => g.Last()) .ToList(); + } - var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).ToList(); - var existingChildIds = childIdsToCheck.Count > 0 - ? context.BaseItems - .Where(e => childIdsToCheck.Contains(e.Id)) - .Select(e => e.Id) - .ToHashSet() - : []; - - var isPlaylist = folder is Playlist; - var sortOrder = 0; - foreach (var (linkedChild, childId) in resolvedChildren) - { - if (!existingChildIds.Contains(childId)) - { - _logger.LogWarning( - "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} does not exist in database", - item.Item.Name, - item.Item.Id, - childId); - continue; - } - - var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId); - if (existingLink is null) - { - context.LinkedChildren.Add(new LinkedChildEntity() - { - ParentId = item.Item.Id, - ChildId = childId, - ChildType = (DbLinkedChildType)linkedChild.Type, - SortOrder = isPlaylist ? sortOrder : null - }); - } - else - { - existingLink.SortOrder = isPlaylist ? sortOrder : null; - existingLink.ChildType = (DbLinkedChildType)linkedChild.Type; - existingLinkedChildren.Remove(existingLink); - } + var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList(); + var existingChildIds = childIdsToCheck.Count > 0 + ? context.BaseItems + .Where(e => childIdsToCheck.Contains(e.Id)) + .Select(e => e.Id) + .ToHashSet() + : []; - sortOrder++; + var sortOrder = 0; + foreach (var (linkedChild, childId) in resolvedChildren) + { + if (!existingChildIds.Contains(childId)) + { +#pragma warning disable CS0618 // Type or member is obsolete - legacy path is logged for diagnostics + _logger.LogWarning( + "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} (path {ChildPath}) does not exist in database", + item.Item.Name, + item.Item.Id, + childId, + linkedChild.Path ?? "unknown"); +#pragma warning restore CS0618 + continue; } - } - if (existingLinkedChildren.Count > 0) - { - context.LinkedChildren.RemoveRange(existingLinkedChildren); + context.LinkedChildren.Add(new LinkedChildEntity() + { + ParentId = item.Item.Id, + ChildId = childId, + ChildType = (DbLinkedChildType)linkedChild.Type, + SortOrder = sortOrder + }); + + sortOrder++; } } if (item.Item is Video video) { - var existingLinkedChildren = (allLinkedChildrenByParent.GetValueOrDefault(video.Id) ?? new List<LinkedChildEntity>()) - .Where(e => (int)e.ChildType == 2 || (int)e.ChildType == 3) - .ToList(); - var newLinkedChildren = new List<(Guid ChildId, LinkedChildType Type)>(); if (video.LocalAlternateVersions.Length > 0) @@ -577,7 +615,7 @@ public class ItemPersistenceService : IItemPersistenceService .ToHashSet() : []; - int sortOrder = 0; + var sortOrder = 0; foreach (var (childId, childType) in newLinkedChildren) { if (!existingChildIds.Contains(childId)) @@ -590,36 +628,27 @@ public class ItemPersistenceService : IItemPersistenceService continue; } - var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId); - if (existingLink is null) + context.LinkedChildren.Add(new LinkedChildEntity { - context.LinkedChildren.Add(new LinkedChildEntity - { - ParentId = video.Id, - ChildId = childId, - ChildType = (DbLinkedChildType)childType, - SortOrder = sortOrder - }); - } - else - { - existingLink.ChildType = (DbLinkedChildType)childType; - existingLink.SortOrder = sortOrder; - existingLinkedChildren.Remove(existingLink); - } + ParentId = video.Id, + ChildId = childId, + ChildType = (DbLinkedChildType)childType, + SortOrder = sortOrder + }); sortOrder++; } - if (existingLinkedChildren.Count > 0) + // A previously-linked LocalAlternateVersion that is no longer present becomes orphaned; + var previousLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(video.Id); + if (previousLinkedChildren is { Count: > 0 }) { - var orphanedLocalVersionIds = existingLinkedChildren - .Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion) + var newChildIds = newLinkedChildren.Select(c => c.ChildId).ToHashSet(); + var orphanedLocalVersionIds = previousLinkedChildren + .Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion && !newChildIds.Contains(e.ChildId)) .Select(e => e.ChildId) .ToList(); - context.LinkedChildren.RemoveRange(existingLinkedChildren); - if (orphanedLocalVersionIds.Count > 0) { var orphanedItems = context.BaseItems diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs index 5e5ce320a5..5f1d9bf87a 100644 --- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs +++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs @@ -159,12 +159,16 @@ public class LinkedChildrenService : ILinkedChildrenService if (existingLink is null) { + var nextSortOrder = (context.LinkedChildren + .Where(lc => lc.ParentId == parentId) + .Max(lc => (int?)lc.SortOrder) ?? -1) + 1; + context.LinkedChildren.Add(new Jellyfin.Database.Implementations.Entities.LinkedChildEntity { ParentId = parentId, ChildId = childId, ChildType = dbChildType, - SortOrder = null + SortOrder = nextSortOrder }); } else diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs index 13c7895f83..0989ce84ba 100644 --- a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs +++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs @@ -87,8 +87,9 @@ public static class StorageHelper /// </summary> private static string ResolvePath(string path) { - var parts = path.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); - var current = Path.DirectorySeparatorChar.ToString(); + var root = Path.GetPathRoot(path) ?? Path.DirectorySeparatorChar.ToString(); + var parts = path.Substring(root.Length).Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); + var current = root; foreach (var part in parts) { current = Path.Combine(current, part); |
