Merge remote-tracking branch 'upstream/master' into fix-byname-queries

# Conflicts:
#	src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
This commit is contained in:
Shadowghost
2026-08-01 21:41:16 +02:00
33 changed files with 3014 additions and 617 deletions
@@ -39,6 +39,19 @@ public static class ExpressionExtensions
return predicates.Aggregate((aggregatePredicate, nextPredicate) => aggregatePredicate.Or(nextPredicate));
}
/// <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>
@@ -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)
@@ -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);
.Where(e => !e.IsFolder && !e.IsVirtualItem);
var playedLeafItems = leafItems
.Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) });
return ApplyAccessFiltering(context, leafItems, new InternalItemsQuery(user) { IncludeOwnedItems = includeOwnedItems });
}
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 });
/// <inheritdoc />
public Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> descendants)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(descendants);
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 });
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);
// 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))));
}
}
@@ -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];
// Leaf items carry their own played state.
var playedItemIds = context.UserData
.Where(ud => ud.UserId == userId && ud.Played)
.Select(ud => ud.ItemId);
// 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();
// 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));
// 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();
var isPlayedFilter = IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
.Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id)));
// 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);
}
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))
});
// 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));
// 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);
}
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))));
}
}
@@ -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)
{
@@ -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)
{
#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()
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();
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)
: [];
if (staleLinks.Count > 0)
{
context.LinkedChildren.RemoveRange(staleLinks);
}
}
}
var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>();
foreach (var linkedChild in folder.LinkedChildren)
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 => !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 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);
}
sortOrder++;
}
}
if (existingLinkedChildren.Count > 0)
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()
: [];
var sortOrder = 0;
foreach (var (linkedChild, childId) in resolvedChildren)
{
context.LinkedChildren.RemoveRange(existingLinkedChildren);
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;
}
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
@@ -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
@@ -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);