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.cs15
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs5
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs183
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs105
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs297
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.cs25
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemCountService.cs156
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs25
-rw-r--r--Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs39
-rw-r--r--Jellyfin.Server.Implementations/Item/OrderMapper.cs2
-rw-r--r--Jellyfin.Server.Implementations/Item/PeopleRepository.cs194
11 files changed, 813 insertions, 233 deletions
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
index 958d11e21e..c2cb644c59 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
@@ -134,6 +134,21 @@ public static class BaseItemMapper
if (dto is Video video)
{
video.PrimaryVersionId = entity.PrimaryVersionId;
+
+ // The LinkedChildren table is the source of truth for version links
+ if (entity.LinkedChildEntities is not null)
+ {
+ video.LinkedAlternateVersions = entity.LinkedChildEntities
+ // LocalAlternateVersion links belong to Video.LocalAlternateVersions, not here
+ .Where(e => e.ChildType == Database.Implementations.Entities.LinkedChildType.LinkedAlternateVersion)
+ .OrderBy(e => e.SortOrder)
+ .Select(e => new LinkedChild
+ {
+ ItemId = e.ChildId,
+ Type = (MediaBrowser.Controller.Entities.LinkedChildType)e.ChildType
+ })
+ .ToArray();
+ }
}
if (dto is IHasSeries hasSeriesName)
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
index 8e917f6951..5a41619390 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
@@ -144,11 +144,6 @@ public sealed partial class BaseItemRepository
{
ArgumentNullException.ThrowIfNull(filter);
- if (!filter.Limit.HasValue)
- {
- filter.EnableTotalRecordCount = false;
- }
-
using var context = _dbProvider.CreateDbContext();
var innerQueryFilter = TranslateQuery(context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)), context, new InternalItemsQuery(filter.User)
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
index 80e16ca310..05ff720ddf 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
@@ -35,11 +35,40 @@ public sealed partial class BaseItemRepository
{
dbQuery = TranslateQuery(dbQuery, context, filter);
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
+ dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter);
dbQuery = ApplyNavigations(dbQuery, filter);
return dbQuery;
}
+ /// <summary>
+ /// Trims an ordered query down to the AdjacentTo item and its immediate neighbours.
+ /// </summary>
+ private IQueryable<BaseItemEntity> ApplyAdjacencyFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
+ {
+ if (filter.AdjacentTo.IsNullOrEmpty())
+ {
+ return dbQuery;
+ }
+
+ // Adjacency is relative to the result set and the order the query asked for, so the ids have
+ // to be read back in that order.
+ var orderedIds = dbQuery.Select(e => e.Id).ToList();
+ var index = orderedIds.IndexOf(filter.AdjacentTo.Value);
+ if (index < 0)
+ {
+ // The item isn't part of this result set, so it has no neighbours in it either.
+ return dbQuery.Take(0);
+ }
+
+ var start = Math.Max(index - 1, 0);
+ var adjacentIds = orderedIds.GetRange(start, Math.Min(index + 2, orderedIds.Count) - start);
+
+ var adjacentQuery = context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => adjacentIds.Contains(e.Id));
+
+ return ApplyOrder(adjacentQuery, filter, context);
+ }
+
private IQueryable<BaseItemEntity> ApplyQueryPaging(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
if (filter.Limit.HasValue || filter.StartIndex.HasValue)
@@ -244,8 +273,8 @@ public sealed partial class BaseItemRepository
dbQuery = dbQuery.Include(e => e.Images);
}
- // Include LinkedChildEntities for container types and videos that use them
- // (BoxSet, Playlist, CollectionFolder for manual linking; Video, Movie for alternate versions).
+ // Include LinkedChildEntities for container types and videos that use them (BoxSet, Playlist,
+ // CollectionFolder for manual linking; every video type for alternate versions).
// When IncludeItemTypes is empty (any type may be returned), always include them to ensure
// LinkedChildren are loaded before items are saved back, preventing accidental deletion.
var linkedChildTypes = new[]
@@ -254,7 +283,10 @@ public sealed partial class BaseItemRepository
BaseItemKind.Playlist,
BaseItemKind.CollectionFolder,
BaseItemKind.Video,
- BaseItemKind.Movie
+ BaseItemKind.Movie,
+ BaseItemKind.Episode,
+ BaseItemKind.MusicVideo,
+ BaseItemKind.Trailer
};
if (filter.IncludeItemTypes.Length == 0 || filter.IncludeItemTypes.Any(linkedChildTypes.Contains))
{
@@ -390,12 +422,24 @@ public sealed partial class BaseItemRepository
var baseQuery = context.BaseItems
.AsNoTracking()
- .Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem);
+ .Where(b => allDescendantIds.Contains(b.Id))
+ .Where(DescendantQueryHelper.IsCountableLeaf);
return ApplyAccessFiltering(context, baseQuery, filter);
}
/// <summary>
+ /// Checks whether the user restricts access to items by parental rating or tags.
+ /// </summary>
+ /// <param name="filter">The query filter.</param>
+ /// <returns><c>true</c> if the query carries parental restrictions.</returns>
+ private static bool RequiresParentalRestrictions(InternalItemsQuery filter)
+ => filter.IncludeInheritedTags.Length > 0
+ || filter.ExcludeInheritedTags.Length > 0
+ || filter.MaxParentalRating is not null
+ || filter.BlockUnratedItems.Length > 0;
+
+ /// <summary>
/// Applies user access filtering to a query.
/// Includes TopParentIds, parental rating, and tag filtering.
/// </summary>
@@ -405,13 +449,127 @@ public sealed partial class BaseItemRepository
IQueryable<BaseItemEntity> baseQuery,
InternalItemsQuery filter)
{
- // Apply TopParentIds filtering (library folder access)
- if (filter.TopParentIds.Length > 0)
+ baseQuery = ApplyTopParentFiltering(context, baseQuery, filter);
+
+ 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.
+ if (!filter.IncludeOwnedItems)
+ {
+ baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
+ }
+
+ return baseQuery;
+ }
+
+ /// <summary>
+ /// Restricts a query to the libraries the user may open, exempting requested by-name items.
+ /// </summary>
+ /// <param name="context">The database context.</param>
+ /// <param name="baseQuery">The query to filter.</param>
+ /// <param name="filter">The query filter.</param>
+ /// <returns>The filtered query.</returns>
+ private IQueryable<BaseItemEntity> ApplyTopParentFiltering(
+ JellyfinDbContext context,
+ IQueryable<BaseItemEntity> baseQuery,
+ InternalItemsQuery filter)
+ {
+ var queryTopParentIds = filter.TopParentIds;
+ if (queryTopParentIds.Length == 0)
+ {
+ return baseQuery;
+ }
+
+ var exemptedItemByNameTypes = GetExemptedItemByNameTypes(filter);
+ if (exemptedItemByNameTypes.Count == 0)
+ {
+ return baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value);
+ }
+
+ baseQuery = baseQuery.Where(e => exemptedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value));
+ if (filter.UserHasContentRestrictions)
+ {
+ baseQuery = ApplyItemByNameAccessFiltering(baseQuery, context, filter, exemptedItemByNameTypes, queryTopParentIds);
+ }
+
+ return baseQuery;
+ }
+
+ /// <summary>
+ /// Returns the by-name types a query asks for, which carry no TopParentId to filter on.
+ /// </summary>
+ /// <param name="filter">The query filter.</param>
+ /// <returns>The type names exempt from library filtering.</returns>
+ private List<string> GetExemptedItemByNameTypes(InternalItemsQuery filter)
+ {
+ var includedItemByNameTypes = GetItemByNameTypesInQuery(filter);
+ if ((filter.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0)
{
- var topParentIds = filter.TopParentIds;
- baseQuery = baseQuery.Where(e => topParentIds.Contains(e.TopParentId!.Value));
+ return includedItemByNameTypes;
}
+ return _itemByNameKinds.Where(filter.IncludeItemTypes.Contains).Select(e => _itemTypeLookup.BaseItemKindNames[e]!).ToList();
+ }
+
+ /// <summary>
+ /// Keeps a by-name row only when at least one item behind its name is reachable for the user.
+ /// </summary>
+ /// <param name="baseQuery">The query to filter.</param>
+ /// <param name="context">The database context.</param>
+ /// <param name="filter">The query filter.</param>
+ /// <param name="itemByNameTypes">The exempted by-name type names.</param>
+ /// <param name="topParentIds">The libraries the user may open.</param>
+ /// <returns>The filtered query.</returns>
+ private IQueryable<BaseItemEntity> ApplyItemByNameAccessFiltering(
+ IQueryable<BaseItemEntity> baseQuery,
+ JellyfinDbContext context,
+ InternalItemsQuery filter,
+ IReadOnlyList<string> itemByNameTypes,
+ Guid[] topParentIds)
+ {
+ // IncludeOwnedItems: a credit on an alternate version of a reachable movie still counts.
+ var accessibleItems = ApplyAccessFiltering(
+ context,
+ context.BaseItems.AsNoTracking(),
+ new InternalItemsQuery(filter.User) { TopParentIds = topParentIds, IncludeOwnedItems = true });
+
+ var personType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
+ if (itemByNameTypes.Contains(personType))
+ {
+ baseQuery = baseQuery.Where(e => e.Type != personType
+ || context.Peoples.Any(p => p.Name == e.Name
+ && context.PeopleBaseItemMap.Any(m => m.PeopleId == p.Id && accessibleItems.Any(i => i.Id == m.ItemId))));
+ }
+
+ foreach (var (kind, valueTypes) in _itemByNameValueTypes)
+ {
+ var typeName = _itemTypeLookup.BaseItemKindNames[kind];
+ if (!itemByNameTypes.Contains(typeName))
+ {
+ continue;
+ }
+
+ baseQuery = baseQuery.Where(e => e.Type != typeName
+ || context.ItemValues.Any(v => valueTypes.Contains(v.Type) && v.CleanValue == e.CleanName
+ && context.ItemValuesMap.Any(m => m.ItemValueId == v.ItemValueId && accessibleItems.Any(i => i.Id == m.ItemId))));
+ }
+
+ return baseQuery;
+ }
+
+ /// <summary>
+ /// Applies the user's parental rating and tag restrictions to a query.
+ /// </summary>
+ /// <param name="context">The database context.</param>
+ /// <param name="baseQuery">The query to filter.</param>
+ /// <param name="filter">The query filter.</param>
+ /// <returns>The filtered query.</returns>
+ private IQueryable<BaseItemEntity> ApplyParentalRestrictions(
+ JellyfinDbContext context,
+ IQueryable<BaseItemEntity> baseQuery,
+ InternalItemsQuery filter)
+ {
// Apply parental rating filtering
if (filter.MaxParentalRating is not null)
{
@@ -462,13 +620,6 @@ public sealed partial class BaseItemRepository
|| e.Type == personTypeName);
}
- // Exclude alternate versions (have PrimaryVersionId set) and 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));
- }
-
return baseQuery;
}
@@ -510,7 +661,7 @@ public sealed partial class BaseItemRepository
var leafItems = context.BaseItems
.AsNoTracking()
- .Where(e => !e.IsFolder && !e.IsVirtualItem);
+ .Where(DescendantQueryHelper.IsCountableLeaf);
return ApplyAccessFiltering(context, leafItems, new InternalItemsQuery(user) { IncludeOwnedItems = includeOwnedItems });
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
index 1ff8d8f863..c9e08b1b5d 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
@@ -49,6 +49,7 @@ public sealed partial class BaseItemRepository
dbQuery = TranslateQuery(dbQuery, context, filter);
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
+ dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter);
if (filter.EnableTotalRecordCount)
{
@@ -75,6 +76,7 @@ public sealed partial class BaseItemRepository
dbQuery = TranslateQuery(dbQuery, context, filter);
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
+ dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter);
var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random);
@@ -108,7 +110,7 @@ public sealed partial class BaseItemRepository
PrepareFilterQuery(filter);
// Early exit if collection type is not supported
- if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music)
+ if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music and not CollectionType.unknown)
{
return [];
}
@@ -119,30 +121,27 @@ public sealed partial class BaseItemRepository
var baseQuery = PrepareItemQuery(context, filter);
baseQuery = TranslateQuery(baseQuery, context, filter);
- if (collectionType == CollectionType.tvshows)
+ if (collectionType is CollectionType.tvshows)
{
return GetLatestTvShowItems(context, baseQuery, filter, limit);
}
if (collectionType is CollectionType.movies)
{
- // Pick, per PresentationUniqueKey, the newest item; return the newest `limit` of those.
- // Build up until limit by streaming through results and deduplicating on the fly.
- var orderedIds = baseQuery
- .Where(e => e.PresentationUniqueKey != null)
- .OrderByDescending(e => e.DateCreated)
- .ThenByDescending(e => e.Id)
- .Select(e => new { e.Id, e.PresentationUniqueKey });
-
- // DistinctBy and Take are lazy, so enumeration stops as soon as limit distinct keys are read.
- var firstIds = orderedIds
- .AsEnumerable()
- .DistinctBy(row => row.PresentationUniqueKey)
- .Select(row => row.Id)
+ return GetLatestMovieItems(context, baseQuery, filter, limit);
+ }
+
+ if (collectionType is CollectionType.unknown)
+ {
+ var moviesQuery = baseQuery.Where(e => e.SeriesName == null);
+ var latestMovies = GetLatestMovieItems(context, moviesQuery, filter, limit);
+ var latestShows = GetLatestTvShowItems(context, baseQuery, filter, limit);
+
+ return latestMovies.Concat(latestShows)
+ .OrderByDescending(dto => dto.DateCreated)
+ .ThenByDescending(dto => dto.Id)
.Take(limit ?? int.MaxValue)
.ToList();
-
- return LoadLatestByIds(context, firstIds, filter);
}
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]!;
@@ -167,7 +166,10 @@ public sealed partial class BaseItemRepository
.Where(album => albumIdsWithMatchingTrack.Contains(album.Id));
}
- var orderedAlbums = topAlbumsQuery
+ // The album is what gets returned, and neither branch above reads it through the
+ // user's filters, so its own parental restrictions have to be applied here: a
+ // matching track does not make an album the user may not see visible.
+ var orderedAlbums = ApplyParentalRestrictions(context, topAlbumsQuery, filter)
.OrderByDescending(album => album.DateCreated)
.ThenByDescending(album => album.Id);
@@ -221,6 +223,39 @@ public sealed partial class BaseItemRepository
}
/// <summary>
+ /// Gets the latest movies, deduplicated so each movie only appears once.
+ /// </summary>
+ /// <param name="context">The database context.</param>
+ /// <param name="baseQuery">The query to pull movies from, with filters already applied.</param>
+ /// <param name="filter">The original query filter, used when loading the final items.</param>
+ /// <param name="limit">How many items to return.</param>
+ /// <returns>The latest movies, newest first.</returns>
+ private IReadOnlyList<BaseItemDto> GetLatestMovieItems(
+ JellyfinDbContext context,
+ IQueryable<BaseItemEntity> baseQuery,
+ InternalItemsQuery filter,
+ int? limit)
+ {
+ // Pick, per PresentationUniqueKey, the newest item; return the newest `limit` of those.
+ // Build up until limit by streaming through results and deduplicating on the fly.
+ var orderedIds = baseQuery
+ .Where(e => e.PresentationUniqueKey != null)
+ .OrderByDescending(e => e.DateCreated)
+ .ThenByDescending(e => e.Id)
+ .Select(e => new { e.Id, e.PresentationUniqueKey });
+
+ // DistinctBy and Take are lazy, so enumeration stops as soon as limit distinct keys are read.
+ var firstIds = orderedIds
+ .AsEnumerable()
+ .DistinctBy(row => row.PresentationUniqueKey)
+ .Select(row => row.Id)
+ .Take(limit ?? int.MaxValue)
+ .ToList();
+
+ return LoadLatestByIds(context, firstIds, filter);
+ }
+
+ /// <summary>
/// Gets the latest TV show items with smart Season/Series container selection.
/// </summary>
/// <remarks>
@@ -420,6 +455,40 @@ public sealed partial class BaseItemRepository
seriesResults.Add((seasonId, seriesId, maxDate, mostRecentEpisodeId));
}
+ // Step 5b: A container is what gets returned, so it has to pass the user's access
+ // filters on its own - a matching episode does not make a Season or Series the user
+ // may not see visible. Containers that don't pass are replaced by their episode.
+ if (RequiresParentalRestrictions(filter) && entitiesToFetch.Count > 0)
+ {
+ var allowedContainerIds = ApplyParentalRestrictions(
+ context,
+ context.BaseItems.AsNoTracking().Where(e => entitiesToFetch.Contains(e.Id)),
+ filter)
+ .Select(e => e.Id)
+ .ToHashSet();
+
+ for (var i = 0; i < seriesResults.Count; i++)
+ {
+ var (seasonId, seriesId, maxDate, mostRecentEpisodeId) = seriesResults[i];
+ if (seasonId.HasValue && !allowedContainerIds.Contains(seasonId.Value))
+ {
+ seasonId = null;
+ }
+
+ if (seriesId.HasValue && !allowedContainerIds.Contains(seriesId.Value))
+ {
+ seriesId = null;
+ }
+
+ if (seasonId is null && seriesId is null)
+ {
+ entitiesToFetch.Add(mostRecentEpisodeId);
+ }
+
+ seriesResults[i] = (seasonId, seriesId, maxDate, mostRecentEpisodeId);
+ }
+ }
+
// Step 6: Fetch the Season/Series entities we decided to return
var entities = entitiesToFetch.Count > 0
? ApplyNavigations(
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index 8e93d22205..623c1ea0ab 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -35,6 +35,18 @@ public sealed partial class BaseItemRepository
// 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;
+ // "und" is the language filters' stand-in for a track that declares no language at all.
+ private static string NormalizeLanguage(string language)
+ => string.Equals(language, "und", StringComparison.OrdinalIgnoreCase) ? "und" : language;
+
+ // The primary versions whose alternate version satisfies a dimension bound. Anchored on
+ // PrimaryVersionId so the filtered index carries it rather than a scan of every item.
+ private static IQueryable<Guid> VersionsMatchingDimension(JellyfinDbContext context, Expression<Func<BaseItemEntity, bool>> bound)
+ => context.BaseItems
+ .Where(v => v.PrimaryVersionId != null)
+ .Where(bound)
+ .Select(v => v.PrimaryVersionId!.Value);
+
/// <inheritdoc />
public IQueryable<BaseItemEntity> TranslateQuery(
IQueryable<BaseItemEntity> baseQuery,
@@ -70,47 +82,86 @@ public sealed partial class BaseItemRepository
include4K = true;
}
- // Non-folders: check own resolution directly (no subquery).
- // Folders (Series, BoxSets): EXISTS check on descendants/linked children.
- // Using navigation properties (a.Item, lc.Child) produces efficient
- // EXISTS + JOIN instead of nested IN (SELECT ...) subqueries.
+ // A 4K remux of an SD primary is a version of the same item, so the bucket a caller filters
+ // on is the best any of the item's versions offers, not just the primary file's. Three sets,
+ // because a bucket is as much about what the version group does not have as what it does, and
+ // because an unprobed primary can still be placed by a version that does carry dimensions.
+ // The filtered PrimaryVersionId index keeps all three to the few items that have versions.
+ var versionsSd = VersionsMatchingDimension(context, v => v.Width > 0 && v.Width < HDWidth);
+ var versionsHd = VersionsMatchingDimension(context, v => v.Width >= HDWidth);
+ var versions4K = VersionsMatchingDimension(context, v => v.Width >= UHDWidth || v.Height >= UHDHeight);
+
+ // Only the SD test needs the Width > 0 guard against a row with no dimensions: such a row
+ // cannot reach the HD or 4K bound anyway, and EF lowers the HD bucket's negated "not itself
+ // 4K" guard to CASE WHEN ... THEN 0 ELSE 1, which already reads unknown as not 4K rather
+ // than propagating a null. Folders (Series, BoxSets) answer on their descendants, bucketed
+ // exactly as a top-level item is so that the two cannot disagree; the navigation properties
+ // (a.Item, lc.Child) give EXISTS + JOIN rather than nested IN (SELECT ...).
baseQuery = baseQuery.Where(e =>
- (!e.IsFolder && e.Width > 0
- && ((includeSD && e.Width < HDWidth)
- || (includeHD && e.Width >= HDWidth && !(e.Width >= UHDWidth || e.Height >= UHDHeight))
- || (include4K && (e.Width >= UHDWidth || e.Height >= UHDHeight))))
+ (!e.IsFolder
+ && ((includeSD
+ && ((e.Width > 0 && e.Width < HDWidth) || versionsSd.Contains(e.Id))
+ && !versionsHd.Contains(e.Id)
+ && !versions4K.Contains(e.Id))
+ || (includeHD
+ && (e.Width >= HDWidth || versionsHd.Contains(e.Id))
+ && !(e.Width >= UHDWidth || e.Height >= UHDHeight)
+ && !versions4K.Contains(e.Id))
+ || (include4K
+ && (e.Width >= UHDWidth || e.Height >= UHDHeight || versions4K.Contains(e.Id)))))
|| (e.IsFolder
&& (e.Children!.Any(a =>
- a.Item.Width > 0
- && ((includeSD && a.Item.Width < HDWidth)
- || (includeHD && a.Item.Width >= HDWidth && !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight))
- || (include4K && (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight))))
+ (includeSD
+ && ((a.Item.Width > 0 && a.Item.Width < HDWidth) || versionsSd.Contains(a.ItemId))
+ && !versionsHd.Contains(a.ItemId)
+ && !versions4K.Contains(a.ItemId))
+ || (includeHD
+ && (a.Item.Width >= HDWidth || versionsHd.Contains(a.ItemId))
+ && !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight)
+ && !versions4K.Contains(a.ItemId))
+ || (include4K
+ && (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight || versions4K.Contains(a.ItemId))))
|| context.LinkedChildren.Any(lc =>
lc.ParentId == e.Id
- && lc.Child!.Width > 0
- && ((includeSD && lc.Child.Width < HDWidth)
- || (includeHD && lc.Child.Width >= HDWidth && !(lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight))
- || (include4K && (lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight)))))));
- }
-
+ && ((includeSD
+ && ((lc.Child!.Width > 0 && lc.Child!.Width < HDWidth) || versionsSd.Contains(lc.ChildId))
+ && !versionsHd.Contains(lc.ChildId)
+ && !versions4K.Contains(lc.ChildId))
+ || (includeHD
+ && (lc.Child!.Width >= HDWidth || versionsHd.Contains(lc.ChildId))
+ && !(lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight)
+ && !versions4K.Contains(lc.ChildId))
+ || (include4K
+ && (lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight || versions4K.Contains(lc.ChildId))))))));
+ }
+
+ // Same reasoning as the resolution filter: a dimension bound is met if any version meets it.
if (minWidth.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Width >= minWidth);
+ var versionsWideEnough = VersionsMatchingDimension(context, v => v.Width >= minWidth);
+ baseQuery = baseQuery.Where(e => e.Width >= minWidth || versionsWideEnough.Contains(e.Id));
}
if (filter.MinHeight.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Height >= filter.MinHeight);
+ var minHeight = filter.MinHeight;
+ var versionsTallEnough = VersionsMatchingDimension(context, v => v.Height >= minHeight);
+ baseQuery = baseQuery.Where(e => e.Height >= minHeight || versionsTallEnough.Contains(e.Id));
}
+ // An upper bound inverts that: it is met only if no version breaches it, since the item's
+ // resolution is the best its version group offers.
if (maxWidth.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Width <= maxWidth);
+ var versionsTooWide = VersionsMatchingDimension(context, v => v.Width > maxWidth);
+ baseQuery = baseQuery.Where(e => e.Width <= maxWidth && !versionsTooWide.Contains(e.Id));
}
if (filter.MaxHeight.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Height <= filter.MaxHeight);
+ var maxHeight = filter.MaxHeight;
+ var versionsTooTall = VersionsMatchingDimension(context, v => v.Height > maxHeight);
+ baseQuery = baseQuery.Where(e => e.Height <= maxHeight && !versionsTooTall.Contains(e.Id));
}
if (filter.IsLocked.HasValue)
@@ -356,7 +407,7 @@ public sealed partial class BaseItemRepository
}
else
{
- baseQuery = baseQuery.Where(e => e.StartDate > now && e.EndDate < now);
+ baseQuery = baseQuery.Where(e => e.StartDate > now || e.EndDate < now);
}
}
@@ -370,14 +421,16 @@ public sealed partial class BaseItemRepository
p => p.Name,
(b, p) => p.Id);
+ var personTypes = filter.PersonTypes;
baseQuery = baseQuery
.Where(e => context.PeopleBaseItemMap
- .Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId)));
+ .Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId) && (personTypes.Length == 0 || personTypes.Contains(m.People.PersonType))));
}
if (!string.IsNullOrWhiteSpace(filter.Person))
{
- baseQuery = baseQuery.Where(e => e.Peoples!.Any(f => f.People.Name == filter.Person));
+ var personTypes = filter.PersonTypes;
+ baseQuery = baseQuery.Where(e => e.Peoples!.Any(f => f.People.Name == filter.Person && (personTypes.Length == 0 || personTypes.Contains(f.People.PersonType))));
}
if (!string.IsNullOrWhiteSpace(filter.ExternalSeriesId))
@@ -499,16 +552,21 @@ public sealed partial class BaseItemRepository
var inProgress = context.UserData
.Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
- // 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.
+ // Series and Seasons 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)))));
+ // Every other folder kind is a container rather than one continuous piece of media
+ var resumableFolderTypes = _resumableFolderKinds
+ .Select(kind => _itemTypeLookup.BaseItemKindNames.GetValueOrDefault(kind))
+ .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))))));
if (isResumable)
{
@@ -516,7 +574,7 @@ 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 = baseQuery.Where(IsFolderFilter.And(folderResumableFilter)
+ baseQuery = baseQuery.Where(folderIsResumableFilter
.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.
@@ -543,14 +601,14 @@ public sealed partial class BaseItemRepository
var resumableMovieIds = inProgress
.Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id);
- baseQuery = baseQuery.Where(IsFolderFilter.And(folderResumableFilter.Not())
+ baseQuery = baseQuery.Where(IsFolderFilter.And(folderIsResumableFilter.Not())
.Or(IsFolderFilter.Not().And(e => !resumableMovieIds.Contains(e.Id))));
}
}
if (filter.ArtistIds.Length > 0)
{
- baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds);
+ baseQuery = baseQuery.WhereReferencedItem(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds);
}
if (filter.AlbumArtistIds.Length > 0)
@@ -581,12 +639,12 @@ public sealed partial class BaseItemRepository
if (filter.ExcludeArtistIds.Length > 0)
{
- baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true);
+ baseQuery = baseQuery.WhereReferencedItem(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true);
}
if (filter.GenreIds.Count > 0)
{
- baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds.ToArray());
+ baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds);
}
if (filter.Genres.Count > 0)
@@ -612,7 +670,7 @@ public sealed partial class BaseItemRepository
if (filter.StudioIds.Length > 0)
{
- baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds.ToArray());
+ baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds);
}
if (filter.OfficialRatings.Length > 0)
@@ -754,104 +812,144 @@ public sealed partial class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage))
{
- var lang = filter.HasNoAudioTrackWithLanguage;
- var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang));
+ var lang = NormalizeLanguage(filter.HasNoAudioTrackWithLanguage);
+ var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang);
+ // A track only an alternate version carries still belongs to the item a caller sees, so the
+ // item's own streams alone do not decide this. Same for every stream filter below.
+ var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio && ms.Language == lang))
+ (!e.IsFolder
+ && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio
+ && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
+ && !versionsWithAudio.Contains(e.Id))
|| (e.IsFolder && !foldersWithAudio.Contains(e.Id)));
}
if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage))
{
- var lang = filter.HasNoInternalSubtitleTrackWithLanguage;
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false));
+ var lang = NormalizeLanguage(filter.HasNoInternalSubtitleTrackWithLanguage);
+ var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false);
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal && ms.Language == lang))
+ (!e.IsFolder
+ && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal
+ && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
+ && !versionsWithSubtitles.Contains(e.Id))
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
}
if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage))
{
- var lang = filter.HasNoExternalSubtitleTrackWithLanguage;
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true));
+ var lang = NormalizeLanguage(filter.HasNoExternalSubtitleTrackWithLanguage);
+ var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true);
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal && ms.Language == lang))
+ (!e.IsFolder
+ && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal
+ && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
+ && !versionsWithSubtitles.Contains(e.Id))
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
}
if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage))
{
- var lang = filter.HasNoSubtitleTrackWithLanguage;
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang));
+ var lang = NormalizeLanguage(filter.HasNoSubtitleTrackWithLanguage);
+ var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang);
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.Language == lang))
+ (!e.IsFolder
+ && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle
+ && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
+ && !versionsWithSubtitles.Contains(e.Id))
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
}
if (filter.HasSubtitles.HasValue)
{
var hasSubtitles = filter.HasSubtitles.Value;
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasSubtitles());
+ var criteria = new HasSubtitles();
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
if (hasSubtitles)
{
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle))
+ (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)
+ || versionsWithSubtitles.Contains(e.Id)))
|| (e.IsFolder && foldersWithSubtitles.Contains(e.Id)));
}
else
{
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle))
+ (!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)
+ && !versionsWithSubtitles.Contains(e.Id))
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
}
}
if (filter.SubtitleLanguages.Count > 0)
{
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages));
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages);
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle
- && (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))))
+ (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle
+ && (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))
+ || versionsWithSubtitles.Contains(e.Id)))
|| (e.IsFolder && foldersWithSubtitles.Contains(e.Id)));
}
if (filter.AudioLanguages.Count > 0)
{
- var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages));
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages);
+ var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio
- && (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))))
+ (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio
+ && (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))
+ || versionsWithAudio.Contains(e.Id)))
|| (e.IsFolder && foldersWithAudio.Contains(e.Id)));
}
if (filter.HasChapterImages.HasValue)
{
var hasChapterImages = filter.HasChapterImages.Value;
- var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, new HasChapterImages());
+ var criteria = new HasChapterImages();
+ var versionsWithChapterImages = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
if (hasChapterImages)
{
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && e.Chapters!.Any(f => f.ImagePath != null))
+ (!e.IsFolder && (e.Chapters!.Any(f => f.ImagePath != null)
+ || versionsWithChapterImages.Contains(e.Id)))
|| (e.IsFolder && foldersWithChapterImages.Contains(e.Id)));
}
else
{
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null))
+ (!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null)
+ && !versionsWithChapterImages.Contains(e.Id))
|| (e.IsFolder && !foldersWithChapterImages.Contains(e.Id)));
}
}
@@ -958,17 +1056,6 @@ public sealed partial class BaseItemRepository
baseQuery = baseQuery.WhereHasAnyProviderIds(filter.HasAnyProviderIds);
}
- if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0)
- {
- var includeAny = filter.HasAnyProviderIds
- .SelectMany(kvp => kvp.Value.Select(v => $"{kvp.Key}:{v}"))
- .ToArray();
- if (includeAny.Length > 0)
- {
- baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeAny.Contains(f)));
- }
- }
-
if (filter.HasImdbId.HasValue)
{
baseQuery = filter.HasImdbId.Value
@@ -990,21 +1077,7 @@ public sealed partial class BaseItemRepository
: baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != TvdbProviderName));
}
- var queryTopParentIds = filter.TopParentIds;
-
- if (queryTopParentIds.Length > 0)
- {
- var includedItemByNameTypes = GetItemByNameTypesInQuery(filter);
- var enableItemsByName = (filter.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0;
- if (enableItemsByName && includedItemByNameTypes.Count > 0)
- {
- baseQuery = baseQuery.Where(e => includedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value));
- }
- else
- {
- baseQuery = baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value);
- }
- }
+ baseQuery = ApplyTopParentFiltering(context, baseQuery, filter);
if (filter.AncestorIds.Length > 0)
{
@@ -1113,16 +1186,23 @@ public sealed partial class BaseItemRepository
: baseQuery.WhereNeitherItemNorDescendantMatches(context, isPlaceHolder);
}
+ // An extra is owned by the single version of an item it is named after, so an extra on any
+ // version counts for the item itself
+ IQueryable<Guid> WithPrimaryVersions(IQueryable<Guid> ownerIds)
+ => ownerIds.Concat(context.BaseItems
+ .Where(version => version.PrimaryVersionId != null && ownerIds.Contains(version.Id))
+ .Select(version => version.PrimaryVersionId!.Value));
+
if (filter.HasSpecialFeature.HasValue)
{
- var itemsWithExtras = context.BaseItems
+ var itemsWithExtras = WithPrimaryVersions(context.BaseItems
.Where(extra => extra.OwnerId != null
&& extra.ExtraType != null
&& extra.ExtraType != BaseItemExtraType.Unknown
&& extra.ExtraType != BaseItemExtraType.Trailer
&& extra.ExtraType != BaseItemExtraType.ThemeSong
&& extra.ExtraType != BaseItemExtraType.ThemeVideo)
- .Select(extra => extra.OwnerId!.Value)
+ .Select(extra => extra.OwnerId!.Value))
.Distinct();
Expression<Func<BaseItemEntity, bool>> hasExtras = e => itemsWithExtras.Contains(e.Id);
@@ -1134,9 +1214,9 @@ public sealed partial class BaseItemRepository
if (filter.HasTrailer.HasValue)
{
- var trailerOwnerIds = context.BaseItems
+ var trailerOwnerIds = WithPrimaryVersions(context.BaseItems
.Where(extra => extra.ExtraType == BaseItemExtraType.Trailer && extra.OwnerId != null)
- .Select(extra => extra.OwnerId!.Value);
+ .Select(extra => extra.OwnerId!.Value));
Expression<Func<BaseItemEntity, bool>> hasTrailer = e => trailerOwnerIds.Contains(e.Id);
@@ -1147,9 +1227,9 @@ public sealed partial class BaseItemRepository
if (filter.HasThemeSong.HasValue)
{
- var themeSongOwnerIds = context.BaseItems
+ var themeSongOwnerIds = WithPrimaryVersions(context.BaseItems
.Where(extra => extra.ExtraType == BaseItemExtraType.ThemeSong && extra.OwnerId != null)
- .Select(extra => extra.OwnerId!.Value);
+ .Select(extra => extra.OwnerId!.Value));
Expression<Func<BaseItemEntity, bool>> hasThemeSong = e => themeSongOwnerIds.Contains(e.Id);
@@ -1160,9 +1240,9 @@ public sealed partial class BaseItemRepository
if (filter.HasThemeVideo.HasValue)
{
- var themeVideoOwnerIds = context.BaseItems
+ var themeVideoOwnerIds = WithPrimaryVersions(context.BaseItems
.Where(extra => extra.ExtraType == BaseItemExtraType.ThemeVideo && extra.OwnerId != null)
- .Select(extra => extra.OwnerId!.Value);
+ .Select(extra => extra.OwnerId!.Value));
Expression<Func<BaseItemEntity, bool>> hasThemeVideo = e => themeVideoOwnerIds.Contains(e.Id);
@@ -1189,33 +1269,6 @@ public sealed partial class BaseItemRepository
}
}
- if (filter.AdjacentTo.HasValue && !filter.AdjacentTo.Value.IsEmpty())
- {
- var adjacentToId = filter.AdjacentTo.Value;
- var targetItem = context.BaseItems.Where(e => e.Id == adjacentToId).Select(e => new { e.SortName, e.Id }).FirstOrDefault();
- if (targetItem is not null)
- {
- var targetSortName = targetItem.SortName ?? string.Empty;
-
- // Fetch both prev and next adjacent items in a single query using Concat (UNION ALL).
- var adjacentIds = context.BaseItems
- .Where(e => string.Compare(e.SortName, targetSortName) < 0)
- .OrderByDescending(e => e.SortName)
- .Select(e => e.Id)
- .Take(1)
- .Concat(
- context.BaseItems
- .Where(e => string.Compare(e.SortName, targetSortName) > 0)
- .OrderBy(e => e.SortName)
- .Select(e => e.Id)
- .Take(1))
- .ToList();
-
- adjacentIds.Add(adjacentToId);
- baseQuery = baseQuery.Where(e => adjacentIds.Contains(e.Id));
- }
- }
-
return baseQuery;
}
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
index 57041276b7..1d2aa21853 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
@@ -46,6 +46,31 @@ public sealed partial class BaseItemRepository
private static readonly IReadOnlyList<ItemValueType> _getStudiosValueTypes = [ItemValueType.Studios];
private static readonly IReadOnlyList<ItemValueType> _getGenreValueTypes = [ItemValueType.Genre];
+ private static readonly BaseItemKind[] _itemByNameKinds =
+ [
+ BaseItemKind.Person,
+ BaseItemKind.Genre,
+ BaseItemKind.MusicGenre,
+ BaseItemKind.MusicArtist,
+ BaseItemKind.Studio
+ ];
+
+ private static readonly (BaseItemKind Kind, IReadOnlyList<ItemValueType> ValueTypes)[] _itemByNameValueTypes =
+ [
+ (BaseItemKind.Genre, _getGenreValueTypes),
+ (BaseItemKind.MusicGenre, _getGenreValueTypes),
+ (BaseItemKind.MusicArtist, _getAllArtistsValueTypes),
+ (BaseItemKind.Studio, _getStudiosValueTypes)
+ ];
+
+ // The only folder kinds whose children form a single viewing sequence, so playback progress on a
+ // child rolls up to them. Every other folder kind is a container that cannot be resumed.
+ private static readonly BaseItemKind[] _resumableFolderKinds =
+ [
+ BaseItemKind.Series,
+ BaseItemKind.Season
+ ];
+
/// <summary>
/// Initializes a new instance of the <see cref="BaseItemRepository"/> class.
/// </summary>
diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
index 4aa65769fd..c42b5f9581 100644
--- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
@@ -260,19 +260,21 @@ public class ItemCountService : IItemCountService
/// <inheritdoc/>
public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId)
{
+ ArgumentNullException.ThrowIfNull(filter);
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
- var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
+ var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played));
}
/// <inheritdoc/>
public int GetTotalCount(InternalItemsQuery filter, Guid ancestorId)
{
+ ArgumentNullException.ThrowIfNull(filter);
using var dbContext = _dbProvider.CreateDbContext();
- var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
+ var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return baseQuery.Count();
}
@@ -283,10 +285,23 @@ public class ItemCountService : IItemCountService
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
- var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
+ var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id);
}
+ private IQueryable<BaseItemEntity> BuildGroupedDescendantsQuery(JellyfinDbContext dbContext, InternalItemsQuery filter, Guid ancestorId)
+ {
+ var ancestorIds = GetPresentationKeyGroups(dbContext, [ancestorId])[ancestorId];
+ var descendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, ancestorIds).ToArray();
+
+ var baseQuery = dbContext.BaseItems
+ .AsNoTracking()
+ .WhereOneOrMany(descendantIds, b => b.Id)
+ .Where(DescendantQueryHelper.IsCountableLeaf);
+
+ return _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
+ }
+
/// <inheritdoc/>
public (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId)
{
@@ -294,9 +309,10 @@ public class ItemCountService : IItemCountService
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
- var allDescendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, parentId);
+ var allDescendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, [parentId]).ToArray();
var baseQuery = dbContext.BaseItems
- .Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem);
+ .WhereOneOrMany(allDescendantIds, b => b.Id)
+ .Where(DescendantQueryHelper.IsCountableLeaf);
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id);
@@ -317,20 +333,29 @@ public class ItemCountService : IItemCountService
var parentIdsArray = parentIds.ToArray();
var hierarchicalCounts = dbContext.BaseItems
- .Where(b => b.ParentId.HasValue && parentIdsArray.Contains(b.ParentId.Value))
+ .Where(b => b.ParentId.HasValue)
+ .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);
var linkedCounts = dbContext.LinkedChildren
- .Where(lc => parentIdsArray.Contains(lc.ParentId))
+ .WhereOneOrMany(parentIdsArray, lc => lc.ParentId)
.GroupBy(lc => lc.ParentId)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
+ var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray);
+
var result = new Dictionary<Guid, int>();
foreach (var parentId in parentIds)
{
+ if (mergedChildCounts.TryGetValue(parentId, out var mergedCount))
+ {
+ result[parentId] = mergedCount;
+ continue;
+ }
+
var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0);
var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0);
@@ -340,6 +365,50 @@ public class ItemCountService : IItemCountService
return result;
}
+ private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds)
+ {
+ var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds)
+ .Where(group => group.Value.Count > 1)
+ .ToArray();
+
+ if (mergedGroups.Length == 0)
+ {
+ return [];
+ }
+
+ // Only merged folders.
+ var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray();
+ var children = dbContext.BaseItems
+ .AsNoTracking()
+ .Where(b => b.ParentId.HasValue)
+ .WhereOneOrMany(memberIds, b => b.ParentId!.Value)
+ .Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey })
+ .ToArray()
+ .GroupBy(b => b.ParentId)
+ .ToDictionary(
+ g => g.Key,
+ g => g.Select(b => string.IsNullOrEmpty(b.PresentationUniqueKey)
+ ? b.Id.ToString("N", CultureInfo.InvariantCulture)
+ : b.PresentationUniqueKey).ToArray());
+
+ var result = new Dictionary<Guid, int>();
+ foreach (var (parentId, members) in mergedGroups)
+ {
+ var childKeys = new HashSet<string>(StringComparer.Ordinal);
+ foreach (var member in members)
+ {
+ if (children.TryGetValue(member, out var keys))
+ {
+ childKeys.UnionWith(keys);
+ }
+ }
+
+ result[parentId] = childKeys.Count;
+ }
+
+ return result;
+ }
+
/// <inheritdoc/>
public Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user)
{
@@ -352,12 +421,15 @@ public class ItemCountService : IItemCountService
}
using var dbContext = _dbProvider.CreateDbContext();
- var folderIdsArray = folderIds.ToArray();
var filter = new InternalItemsQuery(user);
var userId = user.Id;
+ // Merged series and seasons are stored as one row per folder-item sharing a presentation key.
+ var groups = GetPresentationKeyGroups(dbContext, folderIds);
+ var folderIdsArray = groups.Values.SelectMany(members => members).Distinct().ToArray();
+
var leafItems = dbContext.BaseItems
- .Where(b => !b.IsFolder && !b.IsVirtualItem);
+ .Where(DescendantQueryHelper.IsCountableLeaf);
leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter);
var playedLeafItems = leafItems
@@ -397,7 +469,7 @@ public class ItemCountService : IItemCountService
b => b.Id,
(x, b) => new { FolderId = x.ParentId, b.Id, b.Played });
- var results = ancestorLeaves
+ var countsByFolder = ancestorLeaves
.Union(linkedLeaves)
.Union(linkedFolderLeaves)
.GroupBy(x => x.FolderId)
@@ -409,9 +481,73 @@ public class ItemCountService : IItemCountService
})
.ToDictionary(x => x.FolderId, x => (x.Played, x.Total));
+ var results = new Dictionary<Guid, (int Played, int Total)>();
+ foreach (var (folderId, members) in groups)
+ {
+ var played = 0;
+ var total = 0;
+
+ // Members of a group are distinct folders, so their leaves cannot overlap.
+ foreach (var member in members)
+ {
+ if (countsByFolder.TryGetValue(member, out var counts))
+ {
+ played += counts.Played;
+ total += counts.Total;
+ }
+ }
+
+ if (total > 0 || played > 0)
+ {
+ results[folderId] = (played, total);
+ }
+ }
+
return results;
}
+ private static Dictionary<Guid, List<Guid>> GetPresentationKeyGroups(JellyfinDbContext dbContext, IReadOnlyList<Guid> folderIds)
+ {
+ var requested = dbContext.BaseItems
+ .AsNoTracking()
+ .WhereOneOrMany(folderIds, e => e.Id)
+ .Select(e => new { e.Id, e.PresentationUniqueKey })
+ .ToArray();
+
+ var keys = requested
+ .Select(e => e.PresentationUniqueKey)
+ .Where(key => !string.IsNullOrEmpty(key))
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+
+ // Every item that is not merged carries a key derived from its own id, so in the common case
+ // each group resolves back to the single folder that was asked for.
+ var membersByKey = keys.Length == 0
+ ? []
+ : dbContext.BaseItems
+ .AsNoTracking()
+ .Where(e => e.IsFolder)
+ .WhereOneOrMany(keys, e => e.PresentationUniqueKey!)
+ .Select(e => new { e.Id, Key = e.PresentationUniqueKey! })
+ .ToArray()
+ .GroupBy(e => e.Key, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.Select(e => e.Id).ToList(), StringComparer.Ordinal);
+
+ var keyById = requested.ToDictionary(e => e.Id, e => e.PresentationUniqueKey);
+ var groups = new Dictionary<Guid, List<Guid>>();
+ foreach (var folderId in folderIds)
+ {
+ groups[folderId] = keyById.TryGetValue(folderId, out var key)
+ && !string.IsNullOrEmpty(key)
+ && membersByKey.TryGetValue(key, out var members)
+ && members.Count > 0
+ ? members
+ : [folderId];
+ }
+
+ return groups;
+ }
+
private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId)
{
var result = query
diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
index 827c766449..efff3457a3 100644
--- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
@@ -257,23 +257,19 @@ 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).ToArray();
+ var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToHashSet();
foreach (var item in tuples)
{
var entity = BaseItemMapper.Map(item.Item, _appHost);
entity.TopParentId = item.TopParent?.Id;
- if (!existingItems.Any(e => e == entity.Id))
+ if (!existingItems.Contains(entity.Id))
{
context.BaseItems.Add(entity);
}
else
{
- context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete();
- context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete();
- context.BaseItemMetadataFields.Where(e => e.ItemId == entity.Id).ExecuteDelete();
-
if (entity.Images is { Count: > 0 })
{
context.BaseItemImageInfos.AddRange(entity.Images);
@@ -314,9 +310,11 @@ public class ItemPersistenceService : IItemPersistenceService
}).ToArray();
context.ItemValues.AddRange(missingItemValues);
- var itemValuesStore = existingValues.Concat(missingItemValues).ToArray();
+ var itemValuesStore = existingValues
+ .Concat(missingItemValues)
+ .ToDictionary(e => (e.Type, e.Value));
var valueMap = itemValueMaps
- .Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore.First(g => g.Value == e.Value && g.Type == e.MagicNumber)).DistinctBy(e => e.ItemValueId).ToArray()))
+ .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();
@@ -401,6 +399,15 @@ public class ItemPersistenceService : IItemPersistenceService
}
}
+ // Owned rows of updated items are rewritten wholesale; cleared in one statement per table.
+ if (existingItems.Count > 0)
+ {
+ var updatedIds = existingItems.ToArray();
+ context.BaseItemProviders.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
+ context.BaseItemImageInfos.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
+ context.BaseItemMetadataFields.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
+ }
+
context.SaveChanges();
var folderIds = tuples
@@ -531,7 +538,7 @@ public class ItemPersistenceService : IItemPersistenceService
var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList();
var existingChildIds = childIdsToCheck.Count > 0
? context.BaseItems
- .Where(e => childIdsToCheck.Contains(e.Id))
+ .WhereOneOrMany(childIdsToCheck, e => e.Id)
.Select(e => e.Id)
.ToHashSet()
: [];
diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
index 5f1d9bf87a..de112d7aa4 100644
--- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
+++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
@@ -7,6 +7,7 @@ using System.Collections.Generic;
using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
+using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -60,30 +61,50 @@ public class LinkedChildrenService : ILinkedChildrenService
}
/// <inheritdoc/>
+ public IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds)
+ {
+ if (itemIds.Count == 0)
+ {
+ return new HashSet<Guid>();
+ }
+
+ using var dbContext = _dbProvider.CreateDbContext();
+
+ return dbContext.LinkedChildren
+ .Where(lc => lc.ChildType == DbLinkedChildType.LocalAlternateVersion
+ || lc.ChildType == DbLinkedChildType.LinkedAlternateVersion)
+ .WhereOneOrMany(itemIds, lc => lc.ParentId)
+ .Select(lc => lc.ParentId)
+ .Distinct()
+ .ToHashSet();
+ }
+
+ /// <inheritdoc/>
public IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames)
{
using var dbContext = _dbProvider.CreateDbContext();
- var lowerNames = artistNames.Select(n => n.ToLowerInvariant()).ToArray();
+ var cleanNames = artistNames.Select(n => (Original: n, Clean: n.GetCleanValue())).ToArray();
+ var cleanValues = cleanNames.Select(x => x.Clean).ToArray();
+
var artists = dbContext.BaseItems
.AsNoTracking()
.Where(e => e.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]!)
- .Where(e => lowerNames.Contains(e.Name!.ToLower()))
+ .Where(e => cleanValues.Contains(e.CleanName))
.ToArray();
var lookup = artists
- .GroupBy(e => e.Name!, StringComparer.OrdinalIgnoreCase)
+ .GroupBy(e => e.CleanName!)
.ToDictionary(
g => g.Key,
- g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray(),
- StringComparer.OrdinalIgnoreCase);
+ g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
- var result = new Dictionary<string, MusicArtist[]>(artistNames.Count);
- foreach (var name in artistNames)
+ var result = new Dictionary<string, MusicArtist[]>(cleanNames.Length);
+ foreach (var (original, clean) in cleanNames)
{
- if (lookup.TryGetValue(name, out var artistArray))
+ if (lookup.TryGetValue(clean, out var artistArray))
{
- result[name] = artistArray;
+ result[original] = artistArray;
}
}
diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
index 25ad81ec6c..00b10e44a9 100644
--- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs
+++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
@@ -68,7 +68,7 @@ public static class OrderMapper
(ItemSortBy.DateCreated, _) => e => e.DateCreated,
(ItemSortBy.PremiereDate, _) => e => e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null),
(ItemSortBy.StartDate, _) => e => e.StartDate,
- (ItemSortBy.Name, _) => e => e.SortName,
+ (ItemSortBy.Name, _) => e => e.CleanName,
(ItemSortBy.CommunityRating, _) => e => e.CommunityRating,
(ItemSortBy.ProductionYear, _) => e => e.ProductionYear,
(ItemSortBy.CriticRating, _) => e => e.CriticRating,
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index 9611c5c13a..aaa363b046 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -21,10 +21,11 @@ namespace Jellyfin.Server.Implementations.Item;
/// </summary>
/// <param name="dbProvider">Efcore Factory.</param>
/// <param name="itemTypeLookup">Items lookup service.</param>
+/// <param name="queryHelpers">Shared item query helpers.</param>
/// <remarks>
/// Initializes a new instance of the <see cref="PeopleRepository"/> class.
/// </remarks>
-public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup) : IPeopleRepository
+public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup, IItemQueryHelpers queryHelpers) : IPeopleRepository
{
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider = dbProvider;
@@ -33,12 +34,13 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
{
using var context = _dbProvider.CreateDbContext();
var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter);
+ int? distinctNameCount = null;
// Include PeopleBaseItemMap
if (!filter.ItemId.IsEmpty())
{
dbQuery = dbQuery.Include(p => p.BaseItems!.Where(m => m.ItemId == filter.ItemId))
- .OrderBy(e => e.BaseItems!.First(e => e.ItemId == filter.ItemId).ListOrder)
+ .OrderBy(e => e.BaseItems!.Where(m => m.ItemId == filter.ItemId).Min(m => m.ListOrder))
.ThenBy(e => e.PersonType)
.ThenBy(e => e.Name);
}
@@ -46,17 +48,25 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
{
// The Peoples table has one row per (Name, PersonType), so the same person can
// appear multiple times (e.g. as Actor and GuestStar). Collapse to one row per
- // name so /Persons doesn't return the same BaseItem id repeatedly. Lowercase the
- // grouping key so case-only duplicates collapse together.
- var representativeIds = dbQuery
- .GroupBy(e => e.Name.ToLower())
- .Select(g => g.Min(e => e.Id));
- dbQuery = context.Peoples.AsNoTracking()
- .Where(p => representativeIds.Contains(p.Id))
- .OrderBy(e => e.Name);
+ // name so /Persons doesn't return the same BaseItem id repeatedly, keeping the
+ // lowest id per lowercased name so case-only duplicates collapse together.
+ var candidates = dbQuery;
+ dbQuery = candidates
+ .Where(p => !candidates.Any(other => other.Name.ToLower() == p.Name.ToLower() && other.Id < p.Id))
+ .OrderBy(e => e.Name.ToLower());
+
+ if (filter.EnableTotalRecordCount)
+ {
+ distinctNameCount = candidates.Select(e => e.Name.ToLower()).Distinct().Count();
+ }
+ }
+
+ var count = 0;
+ if (filter.EnableTotalRecordCount)
+ {
+ count = distinctNameCount ?? dbQuery.Count();
}
- var count = dbQuery.Count();
if (filter.StartIndex.HasValue && filter.StartIndex > 0)
{
dbQuery = dbQuery.Skip(filter.StartIndex.Value);
@@ -71,7 +81,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
{
StartIndex = filter.StartIndex ?? 0,
TotalRecordCount = count,
- Items = dbQuery.AsEnumerable().Select(Map).ToArray(),
+ Items = dbQuery.AsEnumerable().SelectMany(MapCredits).ToArray(),
};
}
@@ -107,9 +117,17 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
person.Role = person.Role?.Trim() ?? string.Empty;
}
- // multiple metadata providers can provide the _same_ person; dedupe case-insensitively.
- people = people.DistinctBy(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray();
- var personKeys = people.Select(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray();
+ // Project the values every comparison below needs once, so neither the case folding nor the
+ // enum formatting is repeated per candidate.
+ var credits = people.Select(e => (Person: e, LoweredName: e.Name.ToLowerInvariant(), PersonType: e.Type.ToString(), LoweredRole: e.Role.ToLowerInvariant()));
+
+ // multiple metadata providers can provide the _same_ credit; dedupe case-insensitively.
+ // The role is part of the key because one person can hold several credits of the same type
+ // on an item, e.g. a Writer credited for both the Novel and the Screenplay.
+ 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();
using var transaction = context.Database.BeginTransaction();
@@ -122,23 +140,44 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
.Select(f => f.item)
.ToArray();
- var toAdd = people
- .Where(e => !existingPersons.Any(f => string.Equals(f.Name, e.Name, StringComparison.OrdinalIgnoreCase) && f.PersonType == e.Type.ToString()))
- .Select(Map);
+ var existingPersonKeys = existingPersons.Select(e => (e.Name.ToLowerInvariant(), e.PersonType ?? string.Empty)).ToHashSet();
+
+ var toAdd = distinctPersons
+ .Where(e => !existingPersonKeys.Contains((e.LoweredName, e.PersonType)))
+ .Select(e => Map(e.Person))
+ .ToArray();
context.Peoples.AddRange(toAdd);
context.SaveChanges();
- var personsEntities = toAdd.Concat(existingPersons).ToArray();
+ // The Peoples table can hold case-only duplicates, so keep the first match per key just as
+ // the previous First() lookup did.
+ var personsEntities = new Dictionary<(string LoweredName, string PersonType), People>();
+ foreach (var entity in toAdd.Concat(existingPersons))
+ {
+ 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)
+ {
+ existingMapsByCredit.TryAdd((map.People.Name.ToLowerInvariant(), map.People.PersonType ?? string.Empty, map.Role?.ToLowerInvariant() ?? string.Empty), map);
+ }
var listOrder = 0;
- foreach (var person in people)
+ foreach (var credit in distinctCredits)
{
- var entityPerson = personsEntities.First(e => string.Equals(e.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.PersonType == person.Type.ToString());
- var existingMap = existingMaps.FirstOrDefault(e => string.Equals(e.People.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.People.PersonType == person.Type.ToString() && e.Role == person.Role);
- if (existingMap is null)
+ var entityPerson = personsEntities[(credit.LoweredName, credit.PersonType)];
+ if (existingMapsByCredit.TryGetValue((credit.LoweredName, credit.PersonType, credit.LoweredRole), out var existingMap))
+ {
+ // Update the order for existing mappings
+ existingMap.ListOrder = listOrder;
+ existingMap.SortOrder = credit.Person.SortOrder;
+ // person mapping already exists so remove from list
+ existingMaps.Remove(existingMap);
+ }
+ else
{
context.PeopleBaseItemMap.Add(new PeopleBaseItemMap()
{
@@ -147,18 +186,10 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
People = null!,
PeopleId = entityPerson.Id,
ListOrder = listOrder,
- SortOrder = person.SortOrder,
- Role = person.Role
+ SortOrder = credit.Person.SortOrder,
+ Role = credit.Person.Role
});
}
- else
- {
- // Update the order for existing mappings
- existingMap.ListOrder = listOrder;
- existingMap.SortOrder = person.SortOrder;
- // person mapping already exists so remove from list
- existingMaps.Remove(existingMap);
- }
listOrder++;
}
@@ -205,9 +236,66 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
return result;
}
- private PersonInfo Map(People people)
+ /// <inheritdoc/>
+ public IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds)
+ {
+ using var context = _dbProvider.CreateDbContext();
+ var rows = context.PeopleBaseItemMap
+ .AsNoTracking()
+ .Where(m => itemIds.Contains(m.ItemId))
+ .OrderBy(m => m.ListOrder)
+ .Select(m => new
+ {
+ m.ItemId,
+ m.Role,
+ m.SortOrder,
+ m.People.Id,
+ m.People.Name,
+ m.People.PersonType
+ })
+ .ToList();
+
+ var result = new Dictionary<Guid, IReadOnlyList<PersonInfo>>();
+ foreach (var group in rows.GroupBy(r => r.ItemId))
+ {
+ var people = new List<PersonInfo>();
+ foreach (var row in group)
+ {
+ var personInfo = new PersonInfo
+ {
+ ItemId = row.ItemId,
+ Id = row.Id,
+ Name = row.Name,
+ Role = row.Role,
+ SortOrder = row.SortOrder
+ };
+ if (Enum.TryParse<PersonKind>(row.PersonType, out var kind))
+ {
+ personInfo.Type = kind;
+ }
+
+ people.Add(personInfo);
+ }
+
+ result[group.Key] = people;
+ }
+
+ return result;
+ }
+
+ private IEnumerable<PersonInfo> MapCredits(People people)
+ {
+ var mappings = people.BaseItems;
+ if (mappings is null || mappings.Count == 0)
+ {
+ return [Map(people, null)];
+ }
+
+ return mappings.OrderBy(m => m.ListOrder).Select(m => Map(people, m));
+ }
+
+ private PersonInfo Map(People people, PeopleBaseItemMap? mapping)
{
- var mapping = people.BaseItems?.FirstOrDefault();
var personInfo = new PersonInfo()
{
Id = people.Id,
@@ -240,18 +328,34 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (filter.User is not null && filter.IsFavorite.HasValue)
{
var personType = itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
- var oldQuery = query;
+ var userId = filter.User.Id;
+ var isFavorite = filter.IsFavorite.Value;
+ var favoriteItemIds = context.UserData
+ .Where(u => u.UserId.Equals(userId) && u.IsFavorite == isFavorite)
+ .Select(u => u.ItemId);
- query = context.UserData
- .Where(u => u.Item!.Type == personType && u.IsFavorite == filter.IsFavorite && u.UserId.Equals(filter.User.Id))
- .Join(oldQuery, e => e.Item!.Name, e => e.Name, (item, person) => person)
- .Distinct()
- .AsNoTracking();
+ var favoriteNames = context.BaseItems
+ .Where(b => b.Type == personType && favoriteItemIds.Contains(b.Id))
+ .Select(b => b.Name);
+
+ query = query.Where(e => favoriteNames.Contains(e.Name));
+ }
+
+ if (filter.AccessFilter is not null)
+ {
+ // Keep only people credited on at least one item the user can see.
+ var accessibleItems = queryHelpers.ApplyAccessFiltering(context, context.BaseItems.AsNoTracking(), filter.AccessFilter);
+ query = query.Where(e => context.PeopleBaseItemMap
+ .Any(m => m.PeopleId == e.Id && accessibleItems.Any(i => i.Id == m.ItemId)));
}
if (!filter.ItemId.IsEmpty())
{
- query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.ItemId)));
+ var itemId = filter.ItemId;
+ query = query.Where(e => context.PeopleBaseItemMap
+ .Where(m => m.ItemId.Equals(itemId))
+ .Select(m => m.PeopleId)
+ .Contains(e.Id));
}
if (filter.ParentId != null)
@@ -261,7 +365,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (!filter.AppearsInItemId.IsEmpty())
{
- query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.AppearsInItemId)));
+ var appearsInItemId = filter.AppearsInItemId;
+ query = query.Where(e => context.PeopleBaseItemMap
+ .Where(m => m.ItemId.Equals(appearsInItemId))
+ .Select(m => m.PeopleId)
+ .Contains(e.Id));
}
var queryPersonTypes = filter.PersonTypes.Where(IsValidPersonType).ToList();