aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server.Implementations/Item
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Server.Implementations/Item')
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemMapper.cs36
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs221
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs51
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs24
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs55
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs96
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemCountService.cs418
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs96
-rw-r--r--Jellyfin.Server.Implementations/Item/NextUpService.cs23
-rw-r--r--Jellyfin.Server.Implementations/Item/PeopleRepository.cs64
10 files changed, 817 insertions, 267 deletions
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
index c2cb644c59..6405c8c45d 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
@@ -34,6 +34,40 @@ public static class BaseItemMapper
/// </summary>
private static readonly ConcurrentDictionary<string, Type?> _typeMap = new ConcurrentDictionary<string, Type?>();
+ private static UserData[] DetachUserData(BaseItemEntity entity)
+ {
+ if (entity.UserData is null || entity.UserData.Count == 0)
+ {
+ return [];
+ }
+
+ var detached = new UserData[entity.UserData.Count];
+ var index = 0;
+ foreach (var userData in entity.UserData)
+ {
+ detached[index++] = new UserData
+ {
+ ItemId = userData.ItemId,
+ Item = null,
+ UserId = userData.UserId,
+ User = null,
+ CustomDataKey = userData.CustomDataKey,
+ Rating = userData.Rating,
+ PlaybackPositionTicks = userData.PlaybackPositionTicks,
+ PlayCount = userData.PlayCount,
+ IsFavorite = userData.IsFavorite,
+ LastPlayedDate = userData.LastPlayedDate,
+ Played = userData.Played,
+ AudioStreamIndex = userData.AudioStreamIndex,
+ SubtitleStreamIndex = userData.SubtitleStreamIndex,
+ Likes = userData.Likes,
+ RetentionDate = userData.RetentionDate
+ };
+ }
+
+ return detached;
+ }
+
/// <summary>
/// Maps a Entity to the DTO.
/// </summary>
@@ -87,7 +121,7 @@ public static class BaseItemMapper
dto.OwnerId = entity.OwnerId ?? Guid.Empty;
dto.Width = entity.Width.GetValueOrDefault();
dto.Height = entity.Height.GetValueOrDefault();
- dto.UserData = entity.UserData;
+ dto.UserData = DetachUserData(entity);
if (entity.Provider is not null)
{
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
index 5a41619390..51ac146a6f 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
@@ -117,24 +117,55 @@ public sealed partial class BaseItemRepository
.ToArray();
}
- private string[] GetItemValueNames(IReadOnlyList<ItemValueType> itemValueTypes, IReadOnlyList<string> withItemTypes, IReadOnlyList<string> excludeItemTypes)
+ /// <inheritdoc />
+ public IReadOnlyList<string> GetTagNames(InternalItemsQuery filter)
{
+ ArgumentNullException.ThrowIfNull(filter);
+ PrepareFilterQuery(filter);
+
using var context = _dbProvider.CreateDbContext();
+ var baseQuery = PrepareItemQuery(context, filter);
+ baseQuery = TranslateQuery(baseQuery, context, filter);
+
+ var matchingItemIds = baseQuery.Select(e => e.Id);
- var query = context.ItemValuesMap
+ // Project the join before grouping. Grouping over the ItemValue navigation instead makes EF
+ // re-resolve the aggregate as a correlated subquery per group, which is orders of magnitude slower.
+ return context.ItemValuesMap
.AsNoTracking()
- .Where(e => itemValueTypes.Any(w => w == e.ItemValue.Type));
+ .Join(
+ context.ItemValues,
+ ivm => ivm.ItemValueId,
+ iv => iv.ItemValueId,
+ (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value })
+ .Where(iv => iv.Type == ItemValueType.Tags)
+ .Where(iv => matchingItemIds.Contains(iv.ItemId))
+ .GroupBy(iv => iv.CleanValue)
+ .Select(g => g.Min(iv => iv.Value)!)
+ .OrderBy(t => t)
+ .ToArray();
+ }
+
+ private string[] GetItemValueNames(IReadOnlyList<ItemValueType> itemValueTypes, IReadOnlyList<string> withItemTypes, IReadOnlyList<string> excludeItemTypes)
+ {
+ using var context = _dbProvider.CreateDbContext();
+
+ var maps = context.ItemValuesMap.AsNoTracking();
if (withItemTypes.Count > 0)
{
- query = query.Where(e => withItemTypes.Contains(e.Item.Type));
+ maps = maps.Where(e => withItemTypes.Contains(e.Item.Type));
}
if (excludeItemTypes.Count > 0)
{
- query = query.Where(e => !excludeItemTypes.Contains(e.Item.Type));
+ maps = maps.Where(e => !excludeItemTypes.Contains(e.Item.Type));
}
- return query.Select(e => e.ItemValue)
+ return context.ItemValues
+ .AsNoTracking()
+ .WhereOneOrMany(itemValueTypes, e => e.Type)
+ .Where(e => maps.Any(m => m.ItemValueId == e.ItemValueId))
+ .Select(e => new { e.CleanValue, e.Value })
.GroupBy(e => e.CleanValue)
.Select(g => g.Min(v => v.Value)!)
.ToArray();
@@ -246,14 +277,20 @@ public sealed partial class BaseItemRepository
}
result.StartIndex = filter.StartIndex ?? 0;
- if (filter.IncludeItemTypes.Length > 0)
+ var page = query.AsEnumerable().Where(e => e is not null).ToList();
+
+ if (filter.DtoOptions.ContainsField(ItemFields.ItemCounts))
{
- var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes);
+ var pageCleanNames = page
+ .Where(e => !string.IsNullOrEmpty(e.CleanName))
+ .Select(e => e.CleanName!)
+ .Distinct()
+ .ToList();
+
+ var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes, pageCleanNames);
result.Items =
[
- .. query
- .AsEnumerable()
- .Where(e => e is not null)
+ .. page
.Select(e =>
{
var item = DeserializeBaseItem(e, filter.SkipDeserialization);
@@ -268,9 +305,7 @@ public sealed partial class BaseItemRepository
{
result.Items =
[
- .. query
- .AsEnumerable()
- .Where(e => e != null)
+ .. page
.Select(e => DeserializeBaseItem(e, filter.SkipDeserialization))
.Where(item => item != null)
.Select(item => (item!, (ItemCounts?)null))
@@ -281,14 +316,22 @@ public sealed partial class BaseItemRepository
}
private Dictionary<string, ItemCounts> BuildItemCountsByCleanName(
- Database.Implementations.JellyfinDbContext context,
+ JellyfinDbContext context,
InternalItemsQuery filter,
- IReadOnlyList<ItemValueType> itemValueTypes)
+ IReadOnlyList<ItemValueType> itemValueTypes,
+ IReadOnlyList<string> cleanNames)
{
- var typeSubQuery = new InternalItemsQuery(filter.User)
+ var countsByCleanName = new Dictionary<string, ItemCounts>();
+ if (cleanNames.Count == 0)
+ {
+ return countsByCleanName;
+ }
+
+ // The counts describe everything the value is attached to, not only the types the list was
+ // filtered down to.
+ var scopeQuery = new InternalItemsQuery(filter.User)
{
ExcludeItemTypes = filter.ExcludeItemTypes,
- IncludeItemTypes = filter.IncludeItemTypes,
MediaTypes = filter.MediaTypes,
AncestorIds = filter.AncestorIds,
ExcludeItemIds = filter.ExcludeItemIds,
@@ -298,71 +341,115 @@ public sealed partial class BaseItemRepository
IsPlayed = filter.IsPlayed
};
- var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery)
- .Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type)));
+ var scopedItems = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, scopeQuery);
+ var valueLinks = context.ItemValuesMap
+ .AsNoTracking()
+ .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
+ .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue);
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
- var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
- var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum];
- var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist];
- var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio];
- var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer];
- var itemIds = itemCountQuery.Select(e => e.Id);
// Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite)
// Instead, start from ItemValueMaps and join with BaseItems.
- var rawCounts = context.ItemValuesMap
- .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
- .Where(ivm => itemIds.Contains(ivm.ItemId))
+ var rawCounts = valueLinks
.Join(
- context.BaseItems,
+ scopedItems,
ivm => ivm.ItemId,
e => e.Id,
- (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type })
- .GroupBy(x => new { x.CleanName, x.Type })
- .Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() })
- .AsEnumerable();
+ (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type, e.SeriesId, e.Id })
+ .GroupBy(x => new { x.CleanName, x.Type, x.SeriesId })
+ .Select(g => new { g.Key.CleanName, g.Key.Type, g.Key.SeriesId, Count = g.Select(x => x.Id).Distinct().Count() })
+ .ToList();
+
+ // Only studios and genres pass down from a series to its episodes; an artist credit does not.
+ var inheritsToEpisodes = itemValueTypes.Contains(ItemValueType.Studios) || itemValueTypes.Contains(ItemValueType.Genre);
+ var episodeCounts = inheritsToEpisodes
+ ? BuildEpisodeCountsByCleanName(
+ scopedItems,
+ valueLinks,
+ rawCounts
+ .Where(x => x.Type == episodeTypeName)
+ .Select(x => (x.CleanName, x.SeriesId, x.Count))
+ .ToList(),
+ seriesTypeName,
+ episodeTypeName)
+ : rawCounts
+ .Where(x => x.Type == episodeTypeName)
+ .GroupBy(x => x.CleanName)
+ .ToDictionary(g => g.Key, g => g.Sum(x => x.Count));
- var countsByCleanName = new Dictionary<string, ItemCounts>();
foreach (var group in rawCounts.GroupBy(x => x.CleanName))
{
- var counts = new ItemCounts();
- foreach (var row in group)
- {
- if (row.Type == seriesTypeName)
- {
- counts.SeriesCount += row.Count;
- }
- else if (row.Type == episodeTypeName)
- {
- counts.EpisodeCount += row.Count;
- }
- else if (row.Type == movieTypeName)
- {
- counts.MovieCount += row.Count;
- }
- else if (row.Type == musicAlbumTypeName)
- {
- counts.AlbumCount += row.Count;
- }
- else if (row.Type == musicArtistTypeName)
- {
- counts.ArtistCount += row.Count;
- }
- else if (row.Type == audioTypeName)
- {
- counts.SongCount += row.Count;
- }
- else if (row.Type == trailerTypeName)
- {
- counts.TrailerCount += row.Count;
- }
- }
+ var counts = ItemCountBuilder.Build(_itemTypeLookup, group.Select(row => (row.Type, row.Count)));
+ // Episodes are counted separately: the value is usually only written on the series.
+ ItemCountBuilder.SetEpisodeCount(counts, episodeCounts.GetValueOrDefault(group.Key));
countsByCleanName[group.Key] = counts;
}
+ // A value carried by nothing but the episodes below a tagged series has no row of its own.
+ foreach (var (cleanName, episodeCount) in episodeCounts)
+ {
+ if (!countsByCleanName.ContainsKey(cleanName))
+ {
+ var counts = new ItemCounts();
+ ItemCountBuilder.SetEpisodeCount(counts, episodeCount);
+ countsByCleanName[cleanName] = counts;
+ }
+ }
+
return countsByCleanName;
}
+
+ private static Dictionary<string, int> BuildEpisodeCountsByCleanName(
+ IQueryable<BaseItemEntity> scopedItems,
+ IQueryable<ItemValueMap> valueLinks,
+ IReadOnlyList<(string CleanName, Guid? SeriesId, int Count)> taggedEpisodes,
+ string seriesTypeName,
+ string episodeTypeName)
+ {
+ // Resolved in steps rather than as one union: each of these drives off an index, while the
+ // single-statement form leaves SQLite free to scan every episode in the library instead.
+ var taggedSeries = valueLinks
+ .Join(
+ scopedItems.Where(e => e.Type == seriesTypeName),
+ ivm => ivm.ItemId,
+ e => e.Id,
+ (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, SeriesId = e.Id })
+ .ToList();
+
+ var seriesIds = taggedSeries.Select(x => x.SeriesId).Distinct().ToArray();
+ var episodesPerSeries = seriesIds.Length == 0
+ ? []
+ : scopedItems
+ .Where(e => e.Type == episodeTypeName && e.SeriesId != null)
+ .WhereOneOrMany(seriesIds, e => e.SeriesId!.Value)
+ .GroupBy(e => e.SeriesId!.Value)
+ .Select(g => new { SeriesId = g.Key, Count = g.Count() })
+ .ToDictionary(x => x.SeriesId, x => x.Count);
+
+ var episodeCounts = new Dictionary<string, int>();
+ var seriesByCleanName = new Dictionary<string, HashSet<Guid>>();
+ foreach (var group in taggedSeries.GroupBy(x => x.CleanName))
+ {
+ var series = group.Select(x => x.SeriesId).ToHashSet();
+ seriesByCleanName[group.Key] = series;
+ episodeCounts[group.Key] = series.Sum(id => episodesPerSeries.GetValueOrDefault(id));
+ }
+
+ foreach (var (cleanName, seriesId, count) in taggedEpisodes)
+ {
+ if (seriesId is not null
+ && seriesByCleanName.TryGetValue(cleanName, out var series)
+ && series.Contains(seriesId.Value))
+ {
+ continue;
+ }
+
+ episodeCounts[cleanName] = episodeCounts.GetValueOrDefault(cleanName) + count;
+ }
+
+ return episodeCounts;
+ }
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
index c0067d8392..1a4c9da41c 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
@@ -271,7 +271,7 @@ public sealed partial class BaseItemRepository
if (filter.DtoOptions.EnableImages)
{
- dbQuery = dbQuery.Include(e => e.Images);
+ dbQuery = dbQuery.Include(e => e.Images!.OrderBy(i => i.Id));
}
// Include LinkedChildEntities for container types and videos that use them (BoxSet, Playlist,
@@ -291,7 +291,7 @@ public sealed partial class BaseItemRepository
};
if (filter.IncludeItemTypes.Length == 0 || filter.IncludeItemTypes.Any(linkedChildTypes.Contains))
{
- dbQuery = dbQuery.Include(e => e.LinkedChildEntities);
+ dbQuery = dbQuery.Include(e => e.LinkedChildEntities!.OrderBy(l => l.SortOrder));
}
if (filter.IncludeExtras)
@@ -465,16 +465,23 @@ public sealed partial class BaseItemRepository
baseQuery = ApplyParentalRestrictions(context, baseQuery, filter);
- // Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items.
- // Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those.
+ // Hide alternate versions behind the primary of their library, and exclude owned non-extra
+ // items. Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those.
if (!filter.IncludeOwnedItems)
{
- baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
+ baseQuery = ApplyAlternateVersionFiltering(context, baseQuery)
+ .Where(e => e.OwnerId == null || e.ExtraType != null);
}
return baseQuery;
}
+ private static IQueryable<BaseItemEntity> ApplyAlternateVersionFiltering(
+ JellyfinDbContext context,
+ IQueryable<BaseItemEntity> baseQuery)
+ => baseQuery.Where(e => e.PrimaryVersionId == null
+ || !context.BaseItems.Any(p => p.Id == e.PrimaryVersionId && p.TopParentId == e.TopParentId));
+
/// <summary>
/// Restricts a query to the libraries the user may open, exempting requested by-name items.
/// </summary>
@@ -645,24 +652,26 @@ public sealed partial class BaseItemRepository
{
var maxScore = maxRating.Score;
var maxSubScore = maxRating.SubScore ?? 0;
- var linkedChildren = context.LinkedChildren;
+
+ // Only a manual link makes an item a container of other items.
+ var members = context.LinkedChildren
+ .Where(lc => lc.ChildType == Database.Implementations.Entities.LinkedChildType.Manual);
return e =>
- // Item has a rating: check against limit
- (e.InheritedParentalRatingValue != null
- && (e.InheritedParentalRatingValue < maxScore
- || (e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore)))
- // Item has no rating
- || (e.InheritedParentalRatingValue == null
- && (
- // No linked children (not a BoxSet/Playlist): pass as unrated
- !linkedChildren.Any(lc => lc.ParentId == e.Id)
- // Has linked children: at least one child must be within limits
- || linkedChildren.Any(lc => lc.ParentId == e.Id
- && (lc.Child!.InheritedParentalRatingValue == null
- || lc.Child.InheritedParentalRatingValue < maxScore
- || (lc.Child.InheritedParentalRatingValue == maxScore
- && (lc.Child.InheritedParentalRatingSubValue ?? 0) <= maxSubScore)))));
+ // The item's own rating, where it has one, has to be within the limit. An unrated item
+ // passes here; blocking those is what BlockUnratedItems does.
+ (e.InheritedParentalRatingValue == null
+ || e.InheritedParentalRatingValue < maxScore
+ || (e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore))
+ // A container is only as visible as its members: a BoxSet or Playlist with nothing left
+ // in it for this user is hidden whatever rating it carries itself. BoxSet.IsVisible
+ // applies the same rule in memory, and a count has to agree with the listing it counts.
+ && (!members.Any(lc => lc.ParentId == e.Id)
+ || members.Any(lc => lc.ParentId == e.Id
+ && (lc.Child!.InheritedParentalRatingValue == null
+ || lc.Child.InheritedParentalRatingValue < maxScore
+ || (lc.Child.InheritedParentalRatingValue == maxScore
+ && (lc.Child.InheritedParentalRatingSubValue ?? 0) <= maxSubScore))));
}
/// <inheritdoc />
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
index c9e08b1b5d..8d573569e9 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
@@ -593,10 +593,10 @@ public sealed partial class BaseItemRepository
return dbContext.BaseItems
.Where(e => descendantIds.Contains(e.Id) && !e.IsFolder && !e.IsVirtualItem)
- .All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played));
+ .All(BuildLeafIsPlayedFilter(dbContext, user.Id));
}
- return dbContext.BaseItems.Where(e => e.ParentId == id).All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played));
+ return dbContext.BaseItems.Where(e => e.ParentId == id).All(BuildLeafIsPlayedFilter(dbContext, user.Id));
}
/// <inheritdoc />
@@ -626,18 +626,26 @@ public sealed partial class BaseItemRepository
.ToArray();
var tags = context.ItemValuesMap
- .Where(ivm => ivm.ItemValue.Type == ItemValueType.Tags)
- .Where(ivm => matchingItemIds.Contains(ivm.ItemId))
- .Select(ivm => ivm.ItemValue)
+ .Join(
+ context.ItemValues,
+ ivm => ivm.ItemValueId,
+ iv => iv.ItemValueId,
+ (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value })
+ .Where(iv => iv.Type == ItemValueType.Tags)
+ .Where(iv => matchingItemIds.Contains(iv.ItemId))
.GroupBy(iv => iv.CleanValue)
.Select(g => g.Min(iv => iv.Value))
.OrderBy(t => t)
.ToArray();
var genres = context.ItemValuesMap
- .Where(ivm => ivm.ItemValue.Type == ItemValueType.Genre)
- .Where(ivm => matchingItemIds.Contains(ivm.ItemId))
- .Select(ivm => ivm.ItemValue)
+ .Join(
+ context.ItemValues,
+ ivm => ivm.ItemValueId,
+ iv => iv.ItemValueId,
+ (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value })
+ .Where(iv => iv.Type == ItemValueType.Genre)
+ .Where(iv => matchingItemIds.Contains(iv.ItemId))
.GroupBy(iv => iv.CleanValue)
.Select(g => g.Min(iv => iv.Value))
.OrderBy(g => g)
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index 1e30f0164e..d726f0f143 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -38,22 +38,32 @@ public sealed partial class BaseItemRepository
// Shared by the isPlayed filter and the IsPlayed/IsUnplayed ordering so the two cannot disagree.
private Expression<Func<BaseItemEntity, bool>> BuildIsPlayedFilter(JellyfinDbContext context, User user)
{
- var userId = user.Id;
+ // Folders (Series, Seasons, BoxSets, albums, ...) carry no played state of their own and count
+ // as played once no descendant is left unplayed.
+ var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, user)
+ .Where(BuildLeafIsPlayedFilter(context, user.Id).Not());
+
+ return IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
+ .Or(IsFolderFilter.Not().And(BuildLeafIsPlayedFilter(context, user.Id)));
+ }
- // Leaf items carry their own played state.
+ private static Expression<Func<BaseItemEntity, bool>> BuildLeafIsPlayedFilter(JellyfinDbContext context, Guid userId)
+ {
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, user)
- .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
+ // The primaries of every version group holding a played row, whichever version carries it.
+ var playedGroupIds = context.BaseItems
+ .Where(v => v.PrimaryVersionId != null
+ && context.UserData.Any(ud => ud.UserId == userId
+ && ud.Played
+ && (ud.ItemId == v.Id || ud.ItemId == v.PrimaryVersionId)))
+ .Select(v => v.PrimaryVersionId!.Value);
- return IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
- .Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id)));
+ return e => playedItemIds.Contains(e.Id)
+ || playedGroupIds.Contains(e.Id)
+ || (e.PrimaryVersionId != null && playedGroupIds.Contains(e.PrimaryVersionId.Value));
}
// "und" is the language filters' stand-in for a track that declares no language at all.
@@ -571,8 +581,8 @@ public sealed partial class BaseItemRepository
.ToArray();
var folderIsResumableFilter = IsFolderFilter.And(e => resumableFolderTypes.Contains(e.Type))
.And(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))))));
+ .Or(BuildHasDescendantFilter(context, leafItems.Where(BuildLeafIsPlayedFilter(context, userId)))
+ .And(BuildHasDescendantFilter(context, leafItems.Where(BuildLeafIsPlayedFilter(context, userId).Not())))));
if (isResumable)
{
@@ -797,11 +807,16 @@ public sealed partial class BaseItemRepository
{
// Exclude owned non-extra items from general queries.
// Extras (trailers, etc.) have OwnerId set but also have ExtraType set - keep those.
- // Alternate versions (PrimaryVersionId set) are normally excluded too, but resume queries
- // keep them so the actually-played version can surface instead of collapsing onto the primary.
- baseQuery = filter.IsResumable == true
- ? baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null)
- : baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
+ baseQuery = baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null);
+
+ // Alternate versions (PrimaryVersionId set) are normally hidden behind their primary, but
+ // resume queries keep them so the actually-played version can surface instead of collapsing
+ // onto the primary, and the library scan keeps them so a merged version is not mistaken for
+ // a new item.
+ if (filter.IsResumable != true && !filter.IncludeAlternateVersions)
+ {
+ baseQuery = ApplyAlternateVersionFiltering(context, baseQuery);
+ }
}
if (filter.OwnerIds.Length > 0)
@@ -1091,6 +1106,12 @@ public sealed partial class BaseItemRepository
baseQuery = baseQuery.Where(e => e.Parents!.AsQueryable().Any(ancestorFilter));
}
+ if (filter.DescendantOfId.HasValue)
+ {
+ var descendantIds = DescendantQueryHelper.GetAllDescendantIds(context, filter.DescendantOfId.Value);
+ baseQuery = baseQuery.Where(e => descendantIds.Contains(e.Id));
+ }
+
if (filter.LinkedChildAncestorIds.Length > 0)
{
// Keep folder-like items (BoxSets, Playlists) whose linked children descend from any of the requested ancestor ids.
diff --git a/Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs b/Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs
new file mode 100644
index 0000000000..0f8a1b9dfe
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections.Generic;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Dto;
+
+namespace Jellyfin.Server.Implementations.Item;
+
+/// <summary>
+/// Turns per-type counts into an <see cref="ItemCounts"/>.
+/// </summary>
+internal static class ItemCountBuilder
+{
+ /// <summary>
+ /// Builds the counts of one by-name item.
+ /// </summary>
+ /// <param name="itemTypeLookup">The item type lookup.</param>
+ /// <param name="counts">The counted items, by type name. A type may repeat.</param>
+ /// <returns>The counts.</returns>
+ public static ItemCounts Build(IItemTypeLookup itemTypeLookup, IEnumerable<(string Type, int Count)> counts)
+ {
+ ArgumentNullException.ThrowIfNull(itemTypeLookup);
+ ArgumentNullException.ThrowIfNull(counts);
+
+ var lookup = itemTypeLookup.BaseItemKindNames;
+ var result = new ItemCounts();
+
+ foreach (var (type, count) in counts)
+ {
+ // Accumulated rather than assigned: a caller may group by something finer than the
+ // type and hand the same type over more than once.
+ if (string.Equals(type, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal))
+ {
+ result.AlbumCount += count;
+ }
+ else if (string.Equals(type, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal))
+ {
+ result.ArtistCount += count;
+ }
+ else if (string.Equals(type, lookup[BaseItemKind.Episode], StringComparison.Ordinal))
+ {
+ result.EpisodeCount += count;
+ }
+ else if (string.Equals(type, lookup[BaseItemKind.Movie], StringComparison.Ordinal))
+ {
+ result.MovieCount += count;
+ }
+ else if (string.Equals(type, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal))
+ {
+ result.MusicVideoCount += count;
+ }
+ else if (string.Equals(type, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal))
+ {
+ result.ProgramCount += count;
+ }
+ else if (string.Equals(type, lookup[BaseItemKind.Series], StringComparison.Ordinal))
+ {
+ result.SeriesCount += count;
+ }
+ else if (string.Equals(type, lookup[BaseItemKind.Audio], StringComparison.Ordinal))
+ {
+ result.SongCount += count;
+ }
+ else if (string.Equals(type, lookup[BaseItemKind.Trailer], StringComparison.Ordinal))
+ {
+ result.TrailerCount += count;
+ }
+ else if (string.Equals(type, lookup[BaseItemKind.BoxSet], StringComparison.Ordinal))
+ {
+ result.BoxSetCount += count;
+ }
+ else if (string.Equals(type, lookup[BaseItemKind.Book], StringComparison.Ordinal))
+ {
+ result.BookCount += count;
+ }
+ }
+
+ result.ItemCount = result.TotalItemCount();
+
+ return result;
+ }
+
+ /// <summary>
+ /// Replaces the episode count, which both by-name paths decide separately from the other
+ /// types because a genre or studio is usually written on the series rather than its episodes.
+ /// </summary>
+ /// <param name="counts">The counts to update.</param>
+ /// <param name="episodeCount">The episode count.</param>
+ public static void SetEpisodeCount(ItemCounts counts, int episodeCount)
+ {
+ ArgumentNullException.ThrowIfNull(counts);
+
+ counts.EpisodeCount = episodeCount;
+ counts.ItemCount = counts.TotalItemCount();
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
index c42b5f9581..942161a176 100644
--- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
@@ -7,6 +7,7 @@ using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Persistence;
@@ -125,138 +126,286 @@ public class ItemCountService : IItemCountService
/// <inheritdoc />
public ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter)
{
- using var context = _dbProvider.CreateDbContext();
+ return GetItemCountsForNameItems(kind, [id], relatedItemKinds, accessFilter)[id];
+ }
- var item = context.BaseItems.AsNoTracking()
- .Where(e => e.Id == id)
- .Select(e => new { e.Name, e.CleanName })
- .FirstOrDefault();
+ private static ItemValueType[] GetItemValueTypes(BaseItemKind kind)
+ => kind switch
+ {
+ BaseItemKind.MusicArtist => [ItemValueType.Artist, ItemValueType.AlbumArtist],
+ BaseItemKind.Genre or BaseItemKind.MusicGenre => [ItemValueType.Genre],
+ BaseItemKind.Studio => [ItemValueType.Studios],
+ _ => []
+ };
- if (item is null)
+ /// <inheritdoc />
+ public Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter)
+ {
+ ArgumentNullException.ThrowIfNull(ids);
+ ArgumentNullException.ThrowIfNull(relatedItemKinds);
+ ArgumentNullException.ThrowIfNull(accessFilter);
+
+ var result = new Dictionary<Guid, ItemCounts>();
+ if (ids.Count == 0)
{
- return new ItemCounts();
+ return result;
}
- IQueryable<BaseItemEntity> baseQuery;
- switch (kind)
+ using var context = _dbProvider.CreateDbContext();
+
+ var idsArray = ids as Guid[] ?? ids.ToArray();
+ var nameItems = context.BaseItems.AsNoTracking()
+ .WhereOneOrMany(idsArray, e => e.Id)
+ .Select(e => new NameItem(e.Id, e.Name, e.CleanName))
+ .ToArray();
+
+ foreach (var id in ids)
{
- case BaseItemKind.Person:
- baseQuery = ItemsById(context, context.PeopleBaseItemMap
- .AsNoTracking()
- .Where(m => m.People.Name == item.Name)
- .Select(m => m.ItemId));
- break;
- case BaseItemKind.MusicArtist:
- 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.ItemId));
- break;
- case BaseItemKind.Genre:
- case BaseItemKind.MusicGenre:
- baseQuery = ItemsById(context, context.ItemValuesMap
- .AsNoTracking()
- .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
- && ivm.ItemValue.Type == ItemValueType.Genre)
- .Select(ivm => ivm.ItemId));
- break;
- case BaseItemKind.Studio:
- baseQuery = ItemsById(context, context.ItemValuesMap
- .AsNoTracking()
- .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
- && ivm.ItemValue.Type == ItemValueType.Studios)
- .Select(ivm => ivm.ItemId));
- break;
- case BaseItemKind.Year:
- if (int.TryParse(item.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year))
- {
- baseQuery = context.BaseItems
- .AsNoTracking()
- .Where(e => e.ProductionYear == year);
- }
- else
- {
- return new ItemCounts();
- }
+ result[id] = new ItemCounts();
+ }
- break;
- default:
- return new ItemCounts();
+ if (nameItems.Length == 0)
+ {
+ return result;
}
var typeNames = relatedItemKinds.Select(k => _itemTypeLookup.BaseItemKindNames[k]).ToArray();
- baseQuery = baseQuery.Where(e => typeNames.Contains(e.Type));
+ var related = _queryHelpers.ApplyAccessFiltering(
+ context,
+ context.BaseItems.AsNoTracking().Where(e => typeNames.Contains(e.Type)),
+ accessFilter);
- baseQuery = _queryHelpers.ApplyAccessFiltering(context, baseQuery, accessFilter);
+ var valueTypes = GetItemValueTypes(kind);
+ if (valueTypes.Length > 0)
+ {
+ CountByItemValue(context, related, kind, relatedItemKinds, valueTypes, nameItems, result);
+ }
+ else if (kind == BaseItemKind.Person)
+ {
+ CountByPersonName(context, related, nameItems, result);
+ }
+ else if (kind == BaseItemKind.Year)
+ {
+ CountByProductionYear(related, nameItems, result);
+ }
- var counts = baseQuery
- .GroupBy(x => x.Type)
- .Select(x => new { x.Key, Count = x.Count() })
- .ToArray();
+ return result;
+ }
- var lookup = _itemTypeLookup.BaseItemKindNames;
- var result = new ItemCounts();
- var totalCount = 0;
+ private void CountByItemValue(
+ JellyfinDbContext context,
+ IQueryable<BaseItemEntity> related,
+ BaseItemKind kind,
+ BaseItemKind[] relatedItemKinds,
+ ItemValueType[] valueTypes,
+ NameItem[] nameItems,
+ Dictionary<Guid, ItemCounts> result)
+ {
+ var cleanNames = nameItems
+ .Select(n => n.CleanName)
+ .OfType<string>()
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
- foreach (var count in counts)
+ if (cleanNames.Length == 0)
{
- totalCount += count.Count;
+ return;
+ }
- if (string.Equals(count.Key, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal))
- {
- result.AlbumCount = count.Count;
- }
- else if (string.Equals(count.Key, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal))
- {
- result.ArtistCount = count.Count;
- }
- else if (string.Equals(count.Key, lookup[BaseItemKind.Episode], StringComparison.Ordinal))
- {
- result.EpisodeCount = count.Count;
- }
- else if (string.Equals(count.Key, lookup[BaseItemKind.Movie], StringComparison.Ordinal))
- {
- result.MovieCount = count.Count;
- }
- else if (string.Equals(count.Key, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal))
- {
- result.MusicVideoCount = count.Count;
- }
- else if (string.Equals(count.Key, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal))
- {
- result.ProgramCount = count.Count;
- }
- else if (string.Equals(count.Key, lookup[BaseItemKind.Series], StringComparison.Ordinal))
+ var grouped = context.ItemValuesMap.AsNoTracking()
+ .Where(ivm => valueTypes.Contains(ivm.ItemValue.Type))
+ .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue)
+ .Join(related, ivm => ivm.ItemId, e => e.Id, (ivm, e) => new { ivm.ItemValue.CleanValue, e.Type, e.Id })
+ .GroupBy(x => new { x.CleanValue, x.Type })
+ .Select(g => new { g.Key.CleanValue, g.Key.Type, Count = g.Select(x => x.Id).Distinct().Count() })
+ .ToArray();
+
+ var byCleanName = grouped
+ .GroupBy(g => g.CleanValue, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray(), StringComparer.Ordinal);
+
+ var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
+ var episodeRollUp = RollsUpEpisodes(kind, relatedItemKinds)
+ && Array.Exists(grouped, g => string.Equals(g.Type, seriesTypeName, StringComparison.Ordinal))
+ ? CountEpisodesOfTaggedSeriesByCleanName(context, related, valueTypes, cleanNames)
+ : null;
+
+ foreach (var nameItem in nameItems)
+ {
+ if (nameItem.CleanName is null || !byCleanName.TryGetValue(nameItem.CleanName, out var counts))
{
- result.SeriesCount = count.Count;
+ continue;
}
- else if (string.Equals(count.Key, lookup[BaseItemKind.Audio], StringComparison.Ordinal))
+
+ var itemCounts = ItemCountBuilder.Build(_itemTypeLookup, counts);
+
+ if (episodeRollUp is not null)
{
- result.SongCount = count.Count;
+ var rollUp = episodeRollUp.GetValueOrDefault(nameItem.CleanName);
+
+ // Episodes of a tagged series count towards it even when untagged themselves, and
+ // a tagged episode of a tagged series must not be counted a second time.
+ var directEpisodeCount = itemCounts.EpisodeCount - rollUp.TaggedEpisodesOfTaggedSeries;
+ ItemCountBuilder.SetEpisodeCount(itemCounts, rollUp.EpisodesOfTaggedSeries + directEpisodeCount);
}
- else if (string.Equals(count.Key, lookup[BaseItemKind.Trailer], StringComparison.Ordinal))
+
+ result[nameItem.Id] = itemCounts;
+ }
+ }
+
+ private void CountByPersonName(
+ JellyfinDbContext context,
+ IQueryable<BaseItemEntity> related,
+ NameItem[] nameItems,
+ Dictionary<Guid, ItemCounts> result)
+ {
+ var names = nameItems
+ .Select(n => n.Name)
+ .OfType<string>()
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+
+ if (names.Length == 0)
+ {
+ return;
+ }
+
+ var grouped = context.PeopleBaseItemMap.AsNoTracking()
+ .WhereOneOrMany(names, m => m.People.Name)
+ .Join(related, m => m.ItemId, e => e.Id, (m, e) => new { m.People.Name, e.Type, e.Id })
+ .GroupBy(x => new { x.Name, x.Type })
+ // A person can be credited on one item more than once, in different roles.
+ .Select(g => new { g.Key.Name, g.Key.Type, Count = g.Select(x => x.Id).Distinct().Count() })
+ .ToArray();
+
+ ApplyGroupedCounts(nameItems, n => n.Name, grouped.Select(g => (g.Name, g.Type, g.Count)), result);
+ }
+
+ private void CountByProductionYear(
+ IQueryable<BaseItemEntity> related,
+ NameItem[] nameItems,
+ Dictionary<Guid, ItemCounts> result)
+ {
+ var years = new List<int>();
+ foreach (var nameItem in nameItems)
+ {
+ if (int.TryParse(nameItem.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)
+ && !years.Contains(year))
{
- result.TrailerCount = count.Count;
+ years.Add(year);
}
- else if (string.Equals(count.Key, lookup[BaseItemKind.BoxSet], StringComparison.Ordinal))
+ }
+
+ if (years.Count == 0)
+ {
+ return;
+ }
+
+ // No join, so no row can be reached twice and a plain count is the distinct count.
+ var grouped = related
+ .Where(e => e.ProductionYear != null)
+ .WhereOneOrMany(years, e => e.ProductionYear!.Value)
+ .GroupBy(e => new { Year = e.ProductionYear!.Value, e.Type })
+ .Select(g => new { g.Key.Year, g.Key.Type, Count = g.Count() })
+ .ToArray();
+
+ var byYear = grouped
+ .GroupBy(g => g.Year)
+ .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray());
+
+ foreach (var nameItem in nameItems)
+ {
+ if (int.TryParse(nameItem.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)
+ && byYear.TryGetValue(year, out var counts))
{
- result.BoxSetCount = count.Count;
+ result[nameItem.Id] = ItemCountBuilder.Build(_itemTypeLookup, counts);
}
- else if (string.Equals(count.Key, lookup[BaseItemKind.Book], StringComparison.Ordinal))
+ }
+ }
+
+ private void ApplyGroupedCounts(
+ NameItem[] nameItems,
+ Func<NameItem, string?> keySelector,
+ IEnumerable<(string Key, string Type, int Count)> grouped,
+ Dictionary<Guid, ItemCounts> result)
+ {
+ var byKey = grouped
+ .GroupBy(g => g.Key, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray(), StringComparer.Ordinal);
+
+ foreach (var nameItem in nameItems)
+ {
+ var key = keySelector(nameItem);
+ if (key is not null && byKey.TryGetValue(key, out var counts))
{
- result.BookCount = count.Count;
+ result[nameItem.Id] = ItemCountBuilder.Build(_itemTypeLookup, counts);
}
}
+ }
- result.ItemCount = totalCount;
+ private static bool RollsUpEpisodes(BaseItemKind kind, BaseItemKind[] relatedItemKinds)
+ => kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre
+ && relatedItemKinds.Contains(BaseItemKind.Episode)
+ && relatedItemKinds.Contains(BaseItemKind.Series);
+
+ private Dictionary<string, (int EpisodesOfTaggedSeries, int TaggedEpisodesOfTaggedSeries)> CountEpisodesOfTaggedSeriesByCleanName(
+ JellyfinDbContext context,
+ IQueryable<BaseItemEntity> related,
+ ItemValueType[] valueTypes,
+ string[] cleanNames)
+ {
+ var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
+ var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
+
+ var taggedValues = context.ItemValuesMap.AsNoTracking()
+ .Where(ivm => valueTypes.Contains(ivm.ItemValue.Type))
+ .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue);
+
+ // The series carrying each clean name. Distinct, because one item can be mapped to the
+ // same clean name once per value type.
+ var taggedSeries = taggedValues
+ .Join(
+ related.Where(e => e.Type == seriesTypeName),
+ ivm => ivm.ItemId,
+ e => e.Id,
+ (ivm, e) => new { ivm.ItemValue.CleanValue, SeriesId = e.Id })
+ .Distinct();
+
+ var episodes = related.Where(e => e.Type == episodeTypeName && e.SeriesId != null);
+
+ var episodesOfTaggedSeries = taggedSeries
+ .Join(episodes, s => s.SeriesId, e => e.SeriesId!.Value, (s, e) => new { s.CleanValue, e.Id })
+ .GroupBy(x => x.CleanValue)
+ .Select(g => new { CleanValue = g.Key, Count = g.Select(x => x.Id).Distinct().Count() })
+ .ToArray();
+
+ // Episodes that carry the clean name themselves *and* belong to a series carrying it. The
+ // roll-up already counts those, so they have to come off the directly tagged ones.
+ var taggedEpisodesOfTaggedSeries = taggedValues
+ .Join(episodes, ivm => ivm.ItemId, e => e.Id, (ivm, e) => new { ivm.ItemValue.CleanValue, e.Id, e.SeriesId })
+ .Join(
+ taggedSeries,
+ e => new { e.CleanValue, SeriesId = e.SeriesId!.Value },
+ s => new { s.CleanValue, s.SeriesId },
+ (e, s) => new { e.CleanValue, e.Id })
+ .GroupBy(x => x.CleanValue)
+ .Select(g => new { CleanValue = g.Key, Count = g.Select(x => x.Id).Distinct().Count() })
+ .ToArray();
+
+ var taggedLookup = taggedEpisodesOfTaggedSeries
+ .ToDictionary(x => x.CleanValue, x => x.Count, StringComparer.Ordinal);
+
+ // Every clean name in taggedLookup came from an episode of a tagged series, so it always
+ // has a row in episodesOfTaggedSeries too - no second merge pass is needed.
+ var result = new Dictionary<string, (int EpisodesOfTaggedSeries, int TaggedEpisodesOfTaggedSeries)>(StringComparer.Ordinal);
+ foreach (var entry in episodesOfTaggedSeries)
+ {
+ result[entry.CleanValue] = (entry.Count, taggedLookup.GetValueOrDefault(entry.CleanValue));
+ }
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)
{
@@ -265,7 +414,7 @@ public class ItemCountService : IItemCountService
using var dbContext = _dbProvider.CreateDbContext();
var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
- return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played));
+ return baseQuery.Count(DescendantQueryHelper.IsPlayedBy(filter.User.Id));
}
/// <inheritdoc/>
@@ -319,7 +468,7 @@ public class ItemCountService : IItemCountService
}
/// <inheritdoc/>
- public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId)
+ public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user)
{
ArgumentNullException.ThrowIfNull(parentIds);
@@ -332,20 +481,46 @@ public class ItemCountService : IItemCountService
var parentIdsArray = parentIds.ToArray();
- var hierarchicalCounts = dbContext.BaseItems
- .Where(b => b.ParentId.HasValue)
+ var includeVirtual = user is null || user.DisplayMissingEpisodes;
+
+ var accessibleItems = dbContext.BaseItems.AsNoTracking();
+ if (user is null)
+ {
+ // Access filtering is what would otherwise drop an alternate version, and a child count
+ // must not report a title twice just because no user was passed in.
+ accessibleItems = accessibleItems.Where(DescendantQueryHelper.IsDistinctLibraryItem);
+ }
+ else
+ {
+ accessibleItems = _queryHelpers.ApplyAccessFiltering(dbContext, accessibleItems, new InternalItemsQuery(user));
+ }
+
+ var hierarchicalCounts = accessibleItems
+ .Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
.WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value)
.GroupBy(b => b.ParentId!.Value)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
+ // An episode is a child of its season even when it is not stored under one: with a flat
+ // structure ParentId points at the series, so counting by ParentId alone leaves the season
+ // empty and counts its episodes towards the series instead.
+ var seasonCounts = accessibleItems
+ .Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
+ .WhereOneOrMany(parentIdsArray, b => b.SeasonId!.Value)
+ .GroupBy(b => b.SeasonId!.Value)
+ .Select(g => new { SeasonId = g.Key, Count = g.Count() })
+ .ToDictionary(x => x.SeasonId, x => x.Count);
+
+ // A linked child counts only when the item it points at is one the user may open.
var linkedCounts = dbContext.LinkedChildren
.WhereOneOrMany(parentIdsArray, lc => lc.ParentId)
- .GroupBy(lc => lc.ParentId)
+ .Join(accessibleItems, lc => lc.ChildId, b => b.Id, (lc, b) => lc.ParentId)
+ .GroupBy(parentId => parentId)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
- var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray);
+ var mergedChildCounts = GetMergedChildCounts(dbContext, accessibleItems, parentIdsArray, includeVirtual);
var result = new Dictionary<Guid, int>();
foreach (var parentId in parentIds)
@@ -356,7 +531,8 @@ public class ItemCountService : IItemCountService
continue;
}
- var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0);
+ var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0)
+ + seasonCounts.GetValueOrDefault(parentId, 0);
var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0);
result[parentId] = linkedCount > 0 ? linkedCount : hierarchicalCount;
@@ -365,7 +541,11 @@ public class ItemCountService : IItemCountService
return result;
}
- private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds)
+ private static Dictionary<Guid, int> GetMergedChildCounts(
+ JellyfinDbContext dbContext,
+ IQueryable<BaseItemEntity> accessibleItems,
+ IReadOnlyList<Guid> parentIds,
+ bool includeVirtual)
{
var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds)
.Where(group => group.Value.Count > 1)
@@ -378,12 +558,16 @@ public class ItemCountService : IItemCountService
// Only merged folders.
var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray();
- var children = dbContext.BaseItems
- .AsNoTracking()
- .Where(b => b.ParentId.HasValue)
+ var children = accessibleItems
+ .Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
.WhereOneOrMany(memberIds, b => b.ParentId!.Value)
.Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey })
.ToArray()
+ .Concat(accessibleItems
+ .Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
+ .WhereOneOrMany(memberIds, b => b.SeasonId!.Value)
+ .Select(b => new { ParentId = b.SeasonId!.Value, b.Id, b.PresentationUniqueKey })
+ .ToArray())
.GroupBy(b => b.ParentId)
.ToDictionary(
g => g.Key,
@@ -433,7 +617,7 @@ public class ItemCountService : IItemCountService
leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter);
var playedLeafItems = leafItems
- .Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) });
+ .Select(DescendantQueryHelper.PlayedStateBy(userId));
var ancestorLeaves = dbContext.AncestorIds
.WhereOneOrMany(folderIdsArray, a => a.ParentItemId)
@@ -551,7 +735,7 @@ public class ItemCountService : IItemCountService
private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId)
{
var result = query
- .Select(b => b.UserData!.Any(u => u.UserId == userId && u.Played))
+ .Select(DescendantQueryHelper.IsPlayedBy(userId))
.GroupBy(_ => 1)
.OrderBy(g => g.Key)
.Select(g => new
@@ -563,4 +747,12 @@ public class ItemCountService : IItemCountService
return result is null ? (0, 0) : (result.Played, result.Total);
}
+
+ /// <summary>
+ /// A by-name item, reduced to the three columns the counting keys off.
+ /// </summary>
+ /// <param name="Id">The id of the by-name item.</param>
+ /// <param name="Name">The name of the by-name item.</param>
+ /// <param name="CleanName">The cleaned name of the by-name item.</param>
+ private sealed record NameItem(Guid Id, string? Name, string? CleanName);
}
diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
index efff3457a3..a7b0b1c1fc 100644
--- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -68,16 +69,24 @@ public class ItemPersistenceService : IItemPersistenceService
// Use WhereOneOrMany instead of a raw HashSet.Contains so large id sets are bound as a
// single parameter (json_each) rather than one SQL variable per id, which would otherwise
// overflow SQLite's variable limit when deleting many items at once (e.g. migrations).
- var ownerIds = descendantIds.ToArray();
- var extraIds = context.BaseItems
- .Where(e => e.OwnerId.HasValue)
- .WhereOneOrMany(ownerIds, e => e.OwnerId!.Value)
- .Select(e => e.Id)
- .ToArray();
-
- foreach (var extraId in extraIds)
+ var frontier = descendantIds.ToArray();
+ while (frontier.Length > 0)
{
- descendantIds.Add(extraId);
+ var ownedIds = context.BaseItems
+ .Where(e => e.OwnerId.HasValue)
+ .WhereOneOrMany(frontier, e => e.OwnerId!.Value)
+ .Select(e => e.Id)
+ .ToArray();
+
+ var childIds = context.BaseItems
+ .Where(e => e.ParentId.HasValue)
+ .WhereOneOrMany(frontier, e => e.ParentId!.Value)
+ .Select(e => e.Id)
+ .ToArray();
+
+ // Only ids that were not already known become the next frontier, so ownership cycles
+ // terminate instead of looping forever.
+ frontier = [.. ownedIds.Concat(childIds).Where(e => descendantIds.Add(e))];
}
var relatedItems = descendantIds.ToArray();
@@ -136,13 +145,13 @@ public class ItemPersistenceService : IItemPersistenceService
context.ItemValuesMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ParentId).ExecuteDelete();
context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ChildId).ExecuteDelete();
+ var peopleIds = context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).Select(f => f.PeopleId).Distinct().ToArray();
context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDelete();
context.KeyframeData.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.MediaSegments.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.MediaStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
- var query = context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).Select(f => f.PeopleId).Distinct().ToArray();
context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
- context.Peoples.WhereOneOrMany(query, e => e.Id).Where(e => e.BaseItems!.Count == 0).ExecuteDelete();
+ context.Peoples.WhereOneOrMany(peopleIds, e => e.Id).Where(e => !e.BaseItems!.Any()).ExecuteDelete();
context.TrickplayInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.SaveChanges();
transaction.Commit();
@@ -176,14 +185,6 @@ public class ItemPersistenceService : IItemPersistenceService
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
- if (!await context.BaseItems
- .AnyAsync(bi => bi.Id == item.Id, cancellationToken)
- .ConfigureAwait(false))
- {
- _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem");
- return;
- }
-
await context.BaseItemImageInfos
.Where(e => e.ItemId == item.Id)
.ExecuteDeleteAsync(cancellationToken)
@@ -193,7 +194,26 @@ public class ItemPersistenceService : IItemPersistenceService
.AddRangeAsync(images, cancellationToken)
.ConfigureAwait(false);
- await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (DbUpdateException)
+ {
+ // Checking that the item exists before writing leaves a gap a scan can delete it
+ // through, turning the insert into a foreign key violation that fails the whole
+ // refresh instead of the no-op intended here. Let the insert be the check: it is the
+ // only point at which the answer cannot go stale. Nothing is orphaned by the delete
+ // above, because deleting the item cascades to its images anyway.
+ if (await context.BaseItems
+ .AnyAsync(bi => bi.Id == item.Id, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ throw;
+ }
+
+ _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem {ItemId}", item.Id);
+ }
}
}
@@ -257,7 +277,7 @@ public class ItemPersistenceService : IItemPersistenceService
using var transaction = context.Database.BeginTransaction();
var ids = tuples.Select(f => f.Item.Id).ToArray();
- var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToHashSet();
+ var existingItems = context.BaseItems.WhereOneOrMany(ids, e => e.Id).Select(f => f.Id).ToHashSet();
foreach (var item in tuples)
{
@@ -317,7 +337,7 @@ public class ItemPersistenceService : IItemPersistenceService
.Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore[(e.MagicNumber, e.Value)]).DistinctBy(e => e.ItemValueId).ToArray()))
.ToArray();
- var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList();
+ var mappedValues = context.ItemValuesMap.WhereOneOrMany(ids, e => e.ItemId).ToList();
foreach (var item in valueMap)
{
@@ -646,6 +666,38 @@ public class ItemPersistenceService : IItemPersistenceService
sortOrder++;
}
+ var linkedChildIds = newLinkedChildren
+ .Select(c => c.ChildId)
+ // A video listed among its own versions would be pointed at itself.
+ .Where(childId => existingChildIds.Contains(childId) && !childId.Equals(video.Id))
+ .Where(childId => !childId.Equals(video.PrimaryVersionId))
+ .ToList();
+ if (linkedChildIds.Count > 0)
+ {
+ var demotedChildren = context.BaseItems
+ .Where(e => linkedChildIds.Contains(e.Id)
+ && (e.PrimaryVersionId == null || e.PrimaryVersionId != video.Id))
+ .ToList();
+
+ foreach (var child in demotedChildren)
+ {
+ child.PrimaryVersionId = video.Id;
+
+ // Mirrors Video.CreatePresentationUniqueKey, so presentation-key grouping
+ // collapses the version onto its primary as well.
+ child.PresentationUniqueKey = video.Id.ToString("N", CultureInfo.InvariantCulture);
+ }
+
+ if (demotedChildren.Count > 0)
+ {
+ _logger.LogInformation(
+ "Set PrimaryVersionId on {Count} alternate versions of video {VideoName} ({VideoId})",
+ demotedChildren.Count,
+ video.Name,
+ video.Id);
+ }
+ }
+
// A previously-linked LocalAlternateVersion that is no longer present becomes orphaned;
var previousLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(video.Id);
if (previousLinkedChildren is { Count: > 0 })
diff --git a/Jellyfin.Server.Implementations/Item/NextUpService.cs b/Jellyfin.Server.Implementations/Item/NextUpService.cs
index f478daef23..897fb98cbb 100644
--- a/Jellyfin.Server.Implementations/Item/NextUpService.cs
+++ b/Jellyfin.Server.Implementations/Item/NextUpService.cs
@@ -95,7 +95,7 @@ public class NextUpService : INextUpService
.Where(e => e.Type == episodeTypeName)
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber != 0)
- .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
+ .Where(DescendantQueryHelper.IsPlayedBy(userId));
lastWatchedBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedBase, filter);
// Use lightweight projection + client-side dedup to avoid the correlated scalar subquery
@@ -129,12 +129,21 @@ public class NextUpService : INextUpService
// Use an explicit Join (INNER JOIN) instead of SelectMany on a collection navigation.
// SelectMany on UserData with a correlated Where would translate to APPLY,
// which SQLite does not support.
+ // Access filtering leaves only primaries in the base query, but a play can be recorded
+ // against any version, so each row is attributed to its group's primary before the join.
+ var playedByGroupPrimary = context.UserData
+ .AsNoTracking()
+ .Where(ud => ud.ItemId != EF.Constant(BaseItemRepository.PlaceholderId))
+ .Where(ud => ud.Played)
+ .Join(
+ context.BaseItems.AsNoTracking(),
+ ud => ud.ItemId,
+ bi => bi.Id,
+ (ud, bi) => new { ud.UserId, ItemId = bi.PrimaryVersionId ?? bi.Id, ud.LastPlayedDate });
+
var playedWithDates = lastWatchedByDateBase
.Join(
- context.UserData
- .AsNoTracking()
- .Where(ud => ud.ItemId != EF.Constant(BaseItemRepository.PlaceholderId))
- .Where(ud => ud.Played),
+ playedByGroupPrimary,
e => new { UserId = userId, ItemId = e.Id },
ud => new { ud.UserId, ud.ItemId },
(e, ud) => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate })
@@ -198,7 +207,7 @@ public class NextUpService : INextUpService
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber != 0)
.Where(e => !e.IsVirtualItem)
- .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
+ .Where(DescendantQueryHelper.IsUnplayedBy(userId));
allUnplayedBase = _queryHelpers.ApplyAccessFiltering(context, allUnplayedBase, filter);
var allUnplayedCandidates = allUnplayedBase
.Select(e => new
@@ -246,7 +255,7 @@ public class NextUpService : INextUpService
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber != 0)
.Where(e => !e.IsVirtualItem)
- .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
+ .Where(DescendantQueryHelper.IsPlayedBy(userId));
allPlayedBase = _queryHelpers.ApplyAccessFiltering(context, allPlayedBase, filter);
var allPlayedCandidates = allPlayedBase
.Select(e => new
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index da2ad033ec..fcddc09ad9 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -127,18 +127,61 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
var distinctCredits = credits.DistinctBy(e => (e.LoweredName, e.PersonType, e.LoweredRole)).ToArray();
var distinctPersons = distinctCredits.DistinctBy(e => (e.LoweredName, e.PersonType)).ToArray();
- var personKeys = distinctPersons.Select(e => e.LoweredName + "-" + e.PersonType).ToArray();
using var context = _dbProvider.CreateDbContext();
+ var existingMaps = context.PeopleBaseItemMap
+ .AsNoTracking()
+ .Include(e => e.People)
+ .Where(e => e.ItemId == itemId)
+ .ToList();
+
+ // Most library scans refresh unchanged local metadata. Avoid opening a write
+ // transaction when the item's people mappings, order and roles are unchanged.
+ var incomingCredits = distinctCredits
+ .Select((credit, index) => new
+ {
+ Key = (credit.LoweredName, credit.PersonType, credit.LoweredRole),
+ Role = credit.Person.Role,
+ ListOrder = index,
+ SortOrder = credit.Person.SortOrder
+ })
+ .ToDictionary(e => e.Key);
+ var mappingsAreUnchanged = existingMaps.Count == incomingCredits.Count
+ && existingMaps.All(map =>
+ incomingCredits.TryGetValue(
+ (map.People.Name.ToLowerInvariant(), map.People.PersonType ?? string.Empty, map.Role?.ToLowerInvariant() ?? string.Empty),
+ out var incoming)
+ && map.ListOrder == incoming.ListOrder
+ && map.SortOrder == incoming.SortOrder
+ && string.Equals(map.Role ?? string.Empty, incoming.Role, StringComparison.OrdinalIgnoreCase));
+
+ if (mappingsAreUnchanged)
+ {
+ return;
+ }
+
using var transaction = context.Database.BeginTransaction();
- var existingPersons = context.Peoples.Select(e => new
+ // The fast-path snapshot was read before acquiring the write transaction. Reload
+ // tracked mappings inside it so a concurrent refresh cannot leave stale credits.
+ existingMaps = context.PeopleBaseItemMap
+ .Include(e => e.People)
+ .Where(e => e.ItemId == itemId)
+ .ToList();
+
+ // Query each person type separately so SQLite can use IX_Peoples_NameLower.
+ // Combining the two fields into `lower(Name) || '-' || PersonType` forces a full
+ // scan of Peoples for every media item, which is prohibitive during a large import.
+ var existingPersons = new List<People>();
+ foreach (var personTypeGroup in distinctPersons.GroupBy(e => e.PersonType, StringComparer.Ordinal))
{
- item = e,
- SelectionKey = e.Name.ToLower() + "-" + e.PersonType
- })
- .Where(p => personKeys.Contains(p.SelectionKey))
- .Select(f => f.item)
- .ToArray();
+ var names = personTypeGroup
+ .Select(e => e.LoweredName)
+ .ToArray();
+
+ existingPersons.AddRange(context.Peoples
+ .Where(e => e.PersonType == personTypeGroup.Key && names.Contains(e.Name.ToLower()))
+ .ToArray());
+ }
var existingPersonKeys = existingPersons.Select(e => (e.Name.ToLowerInvariant(), e.PersonType ?? string.Empty)).ToHashSet();
@@ -157,7 +200,6 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
personsEntities.TryAdd((entity.Name.ToLowerInvariant(), entity.PersonType ?? string.Empty), entity);
}
- var existingMaps = context.PeopleBaseItemMap.Include(e => e.People).Where(e => e.ItemId == itemId).ToList();
var existingMapsByCredit = new Dictionary<(string LoweredName, string PersonType, string LoweredRole), PeopleBaseItemMap>();
foreach (var map in existingMaps)
{
@@ -238,7 +280,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
using var context = _dbProvider.CreateDbContext();
var query = context.PeopleBaseItemMap
.AsNoTracking()
- .Where(m => itemIds.Contains(m.ItemId));
+ .WhereOneOrMany(itemIds, m => m.ItemId);
if (personTypes.Count > 0)
{
@@ -274,7 +316,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
using var context = _dbProvider.CreateDbContext();
var rows = context.PeopleBaseItemMap
.AsNoTracking()
- .Where(m => itemIds.Contains(m.ItemId))
+ .WhereOneOrMany(itemIds, m => m.ItemId)
.OrderBy(m => m.ListOrder)
.Select(m => new
{