aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server.Implementations
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Server.Implementations')
-rw-r--r--Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs13
-rw-r--r--Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs41
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemMapper.cs17
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs153
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs252
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs124
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs283
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.cs25
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemCountService.cs29
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs246
-rw-r--r--Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs45
-rw-r--r--Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs3
-rw-r--r--Jellyfin.Server.Implementations/Item/NextUpService.cs25
-rw-r--r--Jellyfin.Server.Implementations/Item/OrderMapper.cs29
-rw-r--r--Jellyfin.Server.Implementations/Item/PeopleRepository.cs190
-rw-r--r--Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs5
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs69
17 files changed, 1020 insertions, 529 deletions
diff --git a/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs
index d70ac672f2..0f166fc6e0 100644
--- a/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs
+++ b/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs
@@ -40,6 +40,19 @@ public static class ExpressionExtensions
}
/// <summary>
+ /// Negates a predicate.
+ /// </summary>
+ /// <typeparam name="T">The predicate parameter type.</typeparam>
+ /// <param name="predicate">The predicate expression to negate.</param>
+ /// <returns>A new expression representing the negation of the input predicate.</returns>
+ public static Expression<Func<T, bool>> Not<T>(this Expression<Func<T, bool>> predicate)
+ {
+ ArgumentNullException.ThrowIfNull(predicate);
+
+ return Expression.Lambda<Func<T, bool>>(Expression.Not(predicate.Body), predicate.Parameters);
+ }
+
+ /// <summary>
/// Combines two predicates into a single predicate using a logical AND operation.
/// </summary>
/// <typeparam name="T">The predicate parameter type.</typeparam>
diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
index a534fa5fa0..7c10a5dc77 100644
--- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
+++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
@@ -359,18 +359,39 @@ public class BackupService : IBackupService
jsonSerializer.WriteStartArray();
var set = entityType.ValueFactory().ConfigureAwait(false);
- await foreach (var item in set.ConfigureAwait(false))
+ var enumerator = set.GetAsyncEnumerator();
+ await using (enumerator)
{
- entities++;
- try
+ while (true)
{
- using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings);
- document.WriteTo(jsonSerializer);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Could not load entity {Entity}", item);
- throw;
+ bool hasNext;
+ try
+ {
+ hasNext = await enumerator.MoveNextAsync();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Could not read next entity of type {Table}, the underlying data appears to be corrupt. Skipping this row and continuing backup; the affected database row should be inspected and fixed manually", entityType.SourceName);
+ continue;
+ }
+
+ if (!hasNext)
+ {
+ break;
+ }
+
+ var item = enumerator.Current;
+ entities++;
+ try
+ {
+ using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings);
+ document.WriteTo(jsonSerializer);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Could not load entity {Entity}", item);
+ throw;
+ }
}
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
index c64e6ac068..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)
@@ -183,7 +198,7 @@ public static class BaseItemMapper
if (dto is Folder folder)
{
folder.DateLastMediaAdded = entity.DateLastMediaAdded ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
- if (entity.LinkedChildEntities is not null && entity.LinkedChildEntities.Count > 0)
+ if (entity.LinkedChildEntities is not null)
{
folder.LinkedChildren = entity.LinkedChildEntities
.OrderBy(e => e.SortOrder)
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
index c5b5fbf6d8..5a41619390 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
@@ -4,9 +4,11 @@ using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.EntityFrameworkCore;
using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
@@ -81,6 +83,40 @@ public sealed partial class BaseItemRepository
_itemTypeLookup.MusicGenreTypes);
}
+ /// <inheritdoc />
+ public IReadOnlyList<string> GetMediaStreamLanguages(InternalItemsQuery filter, MediaStreamType mediaStreamType)
+ {
+ ArgumentNullException.ThrowIfNull(filter);
+
+ using var context = _dbProvider.CreateDbContext();
+
+ return TranslateQuery(
+ context.BaseItems.Include(e => e.MediaStreams).Where(e => e.Id != EF.Constant(PlaceholderId)),
+ context,
+ new InternalItemsQuery(filter.User)
+ {
+ IncludeOwnedItems = filter.IncludeOwnedItems,
+ ExcludeItemTypes = filter.ExcludeItemTypes,
+ IncludeItemTypes = filter.IncludeItemTypes,
+ MediaTypes = filter.MediaTypes,
+ AncestorIds = filter.AncestorIds,
+ ItemIds = filter.ItemIds,
+ TopParentIds = filter.TopParentIds,
+ ParentId = filter.ParentId,
+ IsAiring = filter.IsAiring,
+ IsMovie = filter.IsMovie,
+ IsSports = filter.IsSports,
+ IsKids = filter.IsKids,
+ IsNews = filter.IsNews,
+ IsSeries = filter.IsSeries
+ })
+ .SelectMany(e => e.MediaStreams!)
+ .Where(e => e.StreamType == (MediaStreamTypeEntity)mediaStreamType)
+ .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined
+ .Distinct()
+ .ToArray();
+ }
+
private string[] GetItemValueNames(IReadOnlyList<ItemValueType> itemValueTypes, IReadOnlyList<string> withItemTypes, IReadOnlyList<string> excludeItemTypes)
{
using var context = _dbProvider.CreateDbContext();
@@ -108,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)
@@ -132,21 +163,16 @@ public sealed partial class BaseItemRepository
IsSeries = filter.IsSeries
});
- // Keep this as an IQueryable sub-select. Materializing to a list would inline one
- // bound parameter per CleanValue and hit SQLite's variable cap on libraries with
- // high-cardinality value types (e.g. tens of thousands of artists).
- var matchingCleanValues = context.ItemValuesMap
- .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
- .Join(
- innerQueryFilter,
- ivm => ivm.ItemId,
- g => g.Id,
- (ivm, g) => ivm.ItemValue.CleanValue)
- .Distinct();
-
var innerQuery = PrepareItemQuery(context, filter)
.Where(e => e.Type == returnType)
- .Where(e => matchingCleanValues.Contains(e.CleanName!));
+ .Where(e => context.ItemValuesMap
+ .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type) && ivm.ItemValue.CleanValue == e.CleanName)
+ .Join(
+ innerQueryFilter,
+ ivm => ivm.ItemId,
+ g => g.Id,
+ (ivm, g) => ivm.ItemId)
+ .Any());
var outerQueryFilter = new InternalItemsQuery(filter.User)
{
@@ -169,32 +195,42 @@ public sealed partial class BaseItemRepository
ExcludeItemIds = filter.ExcludeItemIds
};
- // Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) by picking
- // the lowest Id per group. For MusicArtist, prefer the entity from a library the user
- // can actually access,since the same artist can have a folder in multiple libraries.
- // Keep as an IQueryable sub-select so paging is applied AFTER
- // ApplyOrder runs the caller's actual sort.
+ // Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) into one
+ // representative id per group, then materialize the representative ids once.
var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter);
var isMusicArtist = returnType == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist];
- var representativeIds = isMusicArtist
- ? masterQuery
+ List<Guid> representativeIds;
+ if (isMusicArtist)
+ {
+ // For MusicArtist, prefer the entity from a library the user can actually access.
+ // Materialize to prevent correlated per-group first-row queries which hurt performance.
+ var topParentIds = filter.TopParentIds;
+ representativeIds = masterQuery
+ .Select(e => new { e.Id, e.PresentationUniqueKey, e.TopParentId })
+ .AsEnumerable()
.GroupBy(e => e.PresentationUniqueKey)
.Select(g => g
- .OrderBy(e => filter.TopParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1)
+ .OrderBy(e => topParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1)
.ThenBy(e => e.Id)
.First().Id)
- : masterQuery
+ .ToList();
+ }
+ else
+ {
+ representativeIds = masterQuery
.GroupBy(e => e.PresentationUniqueKey)
- .Select(g => g.Min(e => e.Id));
+ .Select(g => g.Min(e => e.Id))
+ .ToList();
+ }
var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
if (filter.EnableTotalRecordCount)
{
- result.TotalRecordCount = representativeIds.Count();
+ result.TotalRecordCount = representativeIds.Count;
}
var query = ApplyNavigations(
- context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => representativeIds.Contains(e.Id)),
+ context.BaseItems.AsNoTracking().AsSingleQuery().WhereOneOrMany(representativeIds, e => e.Id),
filter);
query = ApplyOrder(query, filter, context);
@@ -275,8 +311,8 @@ public sealed partial class BaseItemRepository
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
- return context.ItemValuesMap
+ // 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))
.Join(
@@ -286,18 +322,47 @@ public sealed partial class BaseItemRepository
(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() })
- .GroupBy(x => x.CleanName)
- .ToDictionary(
- g => g.Key,
- g => new ItemCounts
+ .AsEnumerable();
+
+ 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)
{
- SeriesCount = g.Where(x => x.Type == seriesTypeName).Sum(x => x.Count),
- EpisodeCount = g.Where(x => x.Type == episodeTypeName).Sum(x => x.Count),
- MovieCount = g.Where(x => x.Type == movieTypeName).Sum(x => x.Count),
- AlbumCount = g.Where(x => x.Type == musicAlbumTypeName).Sum(x => x.Count),
- ArtistCount = g.Where(x => x.Type == musicArtistTypeName).Sum(x => x.Count),
- SongCount = g.Where(x => x.Type == audioTypeName).Sum(x => x.Count),
- TrailerCount = g.Where(x => x.Type == trailerTypeName).Sum(x => x.Count),
- });
+ 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;
+ }
+ }
+
+ countsByCleanName[group.Key] = counts;
+ }
+
+ return countsByCleanName;
}
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
index decd45ae2c..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)
{
- var topParentIds = filter.TopParentIds;
- baseQuery = baseQuery.Where(e => topParentIds.Contains(e.TopParentId!.Value));
+ 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)
+ {
+ 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)
{
@@ -447,6 +605,7 @@ public sealed partial class BaseItemRepository
if (filter.IncludeInheritedTags.Length > 0)
{
var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray();
+ var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
var allowedTagItemIds = context.ItemValuesMap
.Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
.Select(f => f.ItemId);
@@ -455,14 +614,10 @@ public sealed partial class BaseItemRepository
allowedTagItemIds.Contains(e.Id)
|| (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value))
|| e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId))
- || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)));
- }
+ || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value))
- // 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));
+ // People don't carry the tags of the media they appear in and would never match
+ || e.Type == personTypeName);
}
return baseQuery;
@@ -499,62 +654,31 @@ public sealed partial class BaseItemRepository
}
/// <inheritdoc />
- public IQueryable<Guid> GetFullyPlayedFolderIdsQuery(JellyfinDbContext context, IQueryable<Guid> folderIds, User user)
+ public IQueryable<BaseItemEntity> GetAccessFilteredLeafItemsQuery(JellyfinDbContext context, User user, bool includeOwnedItems = false)
{
ArgumentNullException.ThrowIfNull(context);
- ArgumentNullException.ThrowIfNull(folderIds);
ArgumentNullException.ThrowIfNull(user);
- var filter = new InternalItemsQuery(user);
- var userId = user.Id;
-
var leafItems = context.BaseItems
.AsNoTracking()
- .Where(b => !b.IsFolder && !b.IsVirtualItem);
- leafItems = ApplyAccessFiltering(context, leafItems, filter);
+ .Where(DescendantQueryHelper.IsCountableLeaf);
- var playedLeafItems = leafItems
- .Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) });
-
- var ancestorLeaves = context.AncestorIds
- .Where(a => folderIds.Contains(a.ParentItemId))
- .Join(
- playedLeafItems,
- a => a.ItemId,
- b => b.Id,
- (a, b) => new { FolderId = a.ParentItemId, b.Id, b.Played });
-
- var linkedLeaves = context.LinkedChildren
- .Where(lc => folderIds.Contains(lc.ParentId))
- .Join(
- playedLeafItems,
- lc => lc.ChildId,
- b => b.Id,
- (lc, b) => new { FolderId = lc.ParentId, b.Id, b.Played });
+ return ApplyAccessFiltering(context, leafItems, new InternalItemsQuery(user) { IncludeOwnedItems = includeOwnedItems });
+ }
- var linkedFolderLeaves = context.LinkedChildren
- .Where(lc => folderIds.Contains(lc.ParentId))
- .Join(
- context.BaseItems.Where(b => b.IsFolder),
- lc => lc.ChildId,
- b => b.Id,
- (lc, b) => new { lc.ParentId, FolderChildId = b.Id })
- .Join(
- context.AncestorIds,
- x => x.FolderChildId,
- a => a.ParentItemId,
- (x, a) => new { x.ParentId, DescendantId = a.ItemId })
- .Join(
- playedLeafItems,
- x => x.DescendantId,
- b => b.Id,
- (x, b) => new { FolderId = x.ParentId, b.Id, b.Played });
-
- return ancestorLeaves
- .Union(linkedLeaves)
- .Union(linkedFolderLeaves)
- .GroupBy(x => x.FolderId)
- .Where(g => g.Select(x => x.Id).Distinct().Count() == g.Where(x => x.Played).Select(x => x.Id).Distinct().Count())
- .Select(g => g.Key);
+ /// <inheritdoc />
+ public Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> descendants)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(descendants);
+
+ // Descendants are reachable through the ancestor chain and - for BoxSets and Playlists - as
+ // linked children, which can themselves be folders contributing their own descendants.
+ // Every step is a correlated index seek, so only the rows the outer query keeps are visited
+ // and a folder is left as soon as its first matching descendant is found.
+ return e => context.AncestorIds.Any(a => a.ParentItemId == e.Id && descendants.Any(d => d.Id == a.ItemId))
+ || context.LinkedChildren.Any(lc => lc.ParentId == e.Id
+ && (descendants.Any(d => d.Id == lc.ChildId)
+ || context.AncestorIds.Any(a => a.ParentItemId == lc.ChildId && descendants.Any(d => d.Id == a.ItemId))));
}
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
index dc16c3b1b3..c7acf72043 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);
@@ -126,38 +128,57 @@ public sealed partial class BaseItemRepository
if (collectionType is CollectionType.movies)
{
- // Group by PresentationUniqueKey, pick the newest item per group.
- var topGroupItems = baseQuery
+ // 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)
- .GroupBy(e => e.PresentationUniqueKey)
- .Select(g => new
- {
- MaxDate = g.Max(e => e.DateCreated),
- FirstId = g.OrderByDescending(e => e.DateCreated).ThenByDescending(e => e.Id).Select(e => e.Id).First()
- })
- .OrderByDescending(g => g.MaxDate);
+ .OrderByDescending(e => e.DateCreated)
+ .ThenByDescending(e => e.Id)
+ .Select(e => new { e.Id, e.PresentationUniqueKey });
- var firstIdsQuery = filter.Limit.HasValue
- ? topGroupItems.Take(filter.Limit.Value).Select(g => g.FirstId)
- : topGroupItems.Select(g => g.FirstId);
+ // 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, firstIdsQuery, filter);
+ return LoadLatestByIds(context, firstIds, filter);
}
- // Albums whose Id is the parent of any track matching the user's filter.
- var albumIdsWithMatchingTrack = context.AncestorIds
- .Join(baseQuery, ai => ai.ItemId, t => t.Id, (ai, _) => ai.ParentItemId);
-
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]!;
- var topAlbumsQuery = context.BaseItems.AsNoTracking()
- .Where(album => album.Type == musicAlbumTypeName)
- .Where(album => albumIdsWithMatchingTrack.Contains(album.Id))
+ IQueryable<BaseItemEntity> topAlbumsQuery;
+
+ // When the query is scoped to whole libraries, read the newest albums directly by their own TopParentId.
+ if (filter.TopParentIds.Length > 0)
+ {
+ topAlbumsQuery = context.BaseItems.AsNoTracking()
+ .Where(album => album.Type == musicAlbumTypeName
+ && !album.IsVirtualItem
+ && album.TopParentId.HasValue)
+ .WhereOneOrMany(filter.TopParentIds, album => album.TopParentId!.Value);
+ }
+ else
+ {
+ // Fallback (e.g. AncestorIds-scoped callers): albums that are the parent of a matching track.
+ var albumIdsWithMatchingTrack = context.AncestorIds
+ .Join(baseQuery, ai => ai.ItemId, t => t.Id, (ai, _) => ai.ParentItemId);
+ topAlbumsQuery = context.BaseItems.AsNoTracking()
+ .Where(album => album.Type == musicAlbumTypeName)
+ .Where(album => albumIdsWithMatchingTrack.Contains(album.Id));
+ }
+
+ // 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);
- var albumIdsQuery = filter.Limit.HasValue
- ? topAlbumsQuery.Take(filter.Limit.Value).Select(a => a.Id)
- : topAlbumsQuery.Select(a => a.Id);
+ var albumIdsQuery = limit.HasValue
+ ? orderedAlbums.Take(limit.Value).Select(a => a.Id)
+ : orderedAlbums.Select(a => a.Id);
return LoadLatestByIds(context, albumIdsQuery, filter);
}
@@ -181,6 +202,29 @@ public sealed partial class BaseItemRepository
.ToArray()!;
}
+ private IReadOnlyList<BaseItemDto> LoadLatestByIds(
+ JellyfinDbContext context,
+ List<Guid> ids,
+ InternalItemsQuery filter)
+ {
+ if (ids.Count == 0)
+ {
+ return [];
+ }
+
+ var itemsQuery = ApplyNavigations(
+ context.BaseItems.AsNoTracking().WhereOneOrMany(ids, e => e.Id),
+ filter);
+
+ return itemsQuery
+ .OrderByDescending(e => e.DateCreated)
+ .ThenByDescending(e => e.Id)
+ .AsEnumerable()
+ .Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
+ .Where(dto => dto != null)
+ .ToArray()!;
+ }
+
/// <summary>
/// Gets the latest TV show items with smart Season/Series container selection.
/// </summary>
@@ -381,6 +425,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 1198f99473..4be9b04baa 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -31,6 +31,10 @@ public sealed partial class BaseItemRepository
private static readonly string TmdbProviderName = MetadataProvider.Tmdb.ToString().ToLowerInvariant();
private static readonly string TvdbProviderName = MetadataProvider.Tvdb.ToString().ToLowerInvariant();
+ // A fresh expression per access: EF rejects a query tree that reuses one lambda parameter
+ // instance across several lambdas, and this filter is combined into a tree more than once.
+ private static Expression<Func<BaseItemEntity, bool>> IsFolderFilter => e => e.IsFolder;
+
/// <inheritdoc />
public IQueryable<BaseItemEntity> TranslateQuery(
IQueryable<BaseItemEntity> baseQuery,
@@ -352,7 +356,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);
}
}
@@ -366,14 +370,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))
@@ -434,115 +440,82 @@ public sealed partial class BaseItemRepository
if (filter.IsLiked.HasValue)
{
- var isLiked = filter.IsLiked.Value;
- baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.Rating >= UserItemData.MinLikeValue) == isLiked);
- }
+ var likedIds = context.UserData
+ .Where(ud => ud.UserId == filter.User!.Id && ud.Rating >= UserItemData.MinLikeValue)
+ .Select(ud => ud.ItemId);
- if (filter.IsFavoriteOrLiked.HasValue)
- {
- var isFavoriteOrLiked = filter.IsFavoriteOrLiked.Value;
- baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.IsFavorite) == isFavoriteOrLiked);
+ baseQuery = filter.IsLiked.Value
+ ? baseQuery.Where(e => likedIds.Contains(e.Id))
+ : baseQuery.Where(e => !likedIds.Contains(e.Id));
}
- if (filter.IsFavorite.HasValue)
- {
- var isFavorite = filter.IsFavorite.Value;
- baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.IsFavorite) == isFavorite);
- }
-
- if (filter.IsPlayed.HasValue)
+ if (filter.IsFavoriteOrLiked.HasValue || filter.IsFavorite.HasValue)
{
- var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series);
- var hasBoxSet = filter.IncludeItemTypes.Contains(BaseItemKind.BoxSet);
+ var favoriteIds = context.UserData
+ .Where(ud => ud.UserId == filter.User!.Id && ud.IsFavorite)
+ .Select(ud => ud.ItemId);
- if (hasSeries || hasBoxSet)
+ if (filter.IsFavoriteOrLiked.HasValue)
{
- var userId = filter.User!.Id;
- var isPlayed = filter.IsPlayed.Value;
- var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
- var boxSetTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.BoxSet];
-
- // Series: played = at least one episode AND all episodes played; unplayed = otherwise.
- IQueryable<Guid> playedSeriesIds = hasSeries
- ? context.BaseItems
- .AsNoTracking()
- .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue)
- .GroupBy(e => e.SeriesId!.Value)
- .Where(g => !g.Any(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))
- .Select(g => g.Key)
- : Enumerable.Empty<Guid>().AsQueryable();
-
- // BoxSet: played = all children played.
- IQueryable<Guid> playedBoxSetIds = hasBoxSet
- ? GetFullyPlayedFolderIdsQuery(
- context,
- baseQuery.Where(e => e.Type == boxSetTypeName).Select(e => e.Id),
- filter.User!)
- : Enumerable.Empty<Guid>().AsQueryable();
-
- // Non-folder items: check UserData directly
- var playedItemIds = context.UserData
- .Where(ud => ud.UserId == userId && ud.Played)
- .Select(ud => ud.ItemId);
-
- if (isPlayed)
- {
- baseQuery = baseQuery.Where(e =>
- (e.Type == seriesTypeName && playedSeriesIds.Contains(e.Id))
- || (e.Type == boxSetTypeName && playedBoxSetIds.Contains(e.Id))
- || (e.Type != seriesTypeName && e.Type != boxSetTypeName && playedItemIds.Contains(e.Id)));
- }
- else
- {
- baseQuery = baseQuery.Where(e =>
- (e.Type == seriesTypeName && !playedSeriesIds.Contains(e.Id))
- || (e.Type == boxSetTypeName && !playedBoxSetIds.Contains(e.Id))
- || (e.Type != seriesTypeName && e.Type != boxSetTypeName && !playedItemIds.Contains(e.Id)));
- }
+ baseQuery = filter.IsFavoriteOrLiked.Value
+ ? baseQuery.Where(e => favoriteIds.Contains(e.Id))
+ : baseQuery.Where(e => !favoriteIds.Contains(e.Id));
}
- else
+
+ if (filter.IsFavorite.HasValue)
{
- var playedItemIds = context.UserData
- .Where(ud => ud.UserId == filter.User!.Id && ud.Played)
- .Select(ud => ud.ItemId);
- var isPlayedItem = filter.IsPlayed.Value;
- baseQuery = baseQuery.Where(e => playedItemIds.Contains(e.Id) == isPlayedItem);
+ baseQuery = filter.IsFavorite.Value
+ ? baseQuery.Where(e => favoriteIds.Contains(e.Id))
+ : baseQuery.Where(e => !favoriteIds.Contains(e.Id));
}
}
+ if (filter.IsPlayed.HasValue)
+ {
+ var userId = filter.User!.Id;
+
+ // Leaf items carry their own played state.
+ var playedItemIds = context.UserData
+ .Where(ud => ud.UserId == userId && ud.Played)
+ .Select(ud => ud.ItemId);
+
+ // Folders (Series, Seasons, BoxSets, albums, ...) have none and count as played once no
+ // descendant is left unplayed, matching what the DTO reports for them. This has to key off
+ // the item itself rather than off the requested item types: tag and collection listings mix
+ // folders and leaf items in a single query.
+ var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!)
+ .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
+
+ var isPlayedFilter = IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
+ .Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id)));
+
+ baseQuery = baseQuery.Where(filter.IsPlayed.Value ? isPlayedFilter : isPlayedFilter.Not());
+ }
+
if (filter.IsResumable.HasValue)
{
- var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series);
var userId = filter.User!.Id;
var isResumable = filter.IsResumable.Value;
- var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
// In-progress user data rows; alternate versions track their own progress.
var inProgress = context.UserData
.Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
- IQueryable<Guid>? resumableSeriesIds = null;
- if (hasSeries)
- {
- // Aggregate per series in a single GROUP BY pass, instead of three full scans.
- var seriesEpisodeStats = context.BaseItems
- .AsNoTracking()
- .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue)
- .GroupBy(e => e.SeriesId!.Value)
- .Select(g => new
- {
- SeriesId = g.Key,
- HasInProgress = g.Any(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)),
- HasPlayed = g.Any(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)),
- HasUnplayed = g.Any(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))
- });
-
- // A series is resumable if it has an in-progress episode,
- // or if it has both played and unplayed episodes (partially watched).
- resumableSeriesIds = seriesEpisodeStats
- .Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed))
- .Select(s => s.SeriesId);
- }
+ // 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));
+
+ // 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)
{
@@ -550,26 +523,26 @@ public sealed partial class BaseItemRepository
// Match each version on its own progress rather than coalescing onto the primary.
var inProgressIds = inProgress.Select(ud => ud.ItemId);
- baseQuery = hasSeries
- ? baseQuery.Where(e =>
- (e.Type == seriesTypeName && resumableSeriesIds!.Contains(e.Id))
- || (e.Type != seriesTypeName && inProgressIds.Contains(e.Id)))
- : baseQuery.Where(e => inProgressIds.Contains(e.Id));
+ baseQuery = baseQuery.Where(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.
// Only in-progress siblings can eliminate a candidate: a version without progress has a NULL max LastPlayedDate,
// which is never greater and never ties. Restricting the sibling scan to the in-progress set keeps this bounded by
// the user's Continue Watching count instead of forcing a full BaseItems scan (COALESCE keys are non-indexable) per row.
- baseQuery = baseQuery.Where(e => e.Type == seriesTypeName || !context.BaseItems
- .Where(s => s.Id != e.Id
- && inProgressIds.Contains(s.Id)
- && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
- .Any(s =>
- inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
- > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
- || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
- == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
- && s.Id.CompareTo(e.Id) < 0)));
+ // Items in no version group at all have no sibling that could eliminate them, so short-circuit the scan for those.
+ baseQuery = baseQuery.Where(e => e.IsFolder
+ || (e.PrimaryVersionId == null && !context.BaseItems.Any(a => a.PrimaryVersionId == e.Id))
+ || !context.BaseItems
+ .Where(s => s.Id != e.Id
+ && inProgressIds.Contains(s.Id)
+ && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
+ .Any(s =>
+ inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ && s.Id.CompareTo(e.Id) < 0)));
}
else
{
@@ -577,17 +550,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 = hasSeries
- ? baseQuery.Where(e =>
- (e.Type == seriesTypeName && !resumableSeriesIds!.Contains(e.Id))
- || (e.Type != seriesTypeName && !resumableMovieIds.Contains(e.Id)))
- : baseQuery.Where(e => !resumableMovieIds.Contains(e.Id));
+ baseQuery = baseQuery.Where(IsFolderFilter.And(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)
@@ -618,12 +588,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)
@@ -649,7 +619,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)
@@ -995,17 +965,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
@@ -1027,21 +986,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)
{
@@ -1089,6 +1034,7 @@ public sealed partial class BaseItemRepository
{
var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray();
var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist;
+ var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
var allowedTagItemIds = context.ItemValuesMap
.Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
.Select(f => f.ItemId);
@@ -1099,6 +1045,9 @@ public sealed partial class BaseItemRepository
|| e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId))
|| (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value))
+ // People don't carry the tags of the media they appear in and would never match
+ || e.Type == personTypeName
+
// A playlist should be accessible to its owner regardless of allowed tags
|| (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")));
}
@@ -1146,16 +1095,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);
@@ -1167,9 +1123,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);
@@ -1180,9 +1136,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);
@@ -1193,9 +1149,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);
@@ -1222,33 +1178,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 604db9f839..a320ba89d1 100644
--- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
@@ -141,32 +141,32 @@ public class ItemCountService : IItemCountService
switch (kind)
{
case BaseItemKind.Person:
- baseQuery = context.PeopleBaseItemMap
+ baseQuery = ItemsById(context, context.PeopleBaseItemMap
.AsNoTracking()
.Where(m => m.People.Name == item.Name)
- .Select(m => m.Item);
+ .Select(m => m.ItemId));
break;
case BaseItemKind.MusicArtist:
- baseQuery = context.ItemValuesMap
+ baseQuery = ItemsById(context, context.ItemValuesMap
.AsNoTracking()
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
&& (ivm.ItemValue.Type == ItemValueType.Artist || ivm.ItemValue.Type == ItemValueType.AlbumArtist))
- .Select(ivm => ivm.Item);
+ .Select(ivm => ivm.ItemId));
break;
case BaseItemKind.Genre:
case BaseItemKind.MusicGenre:
- baseQuery = context.ItemValuesMap
+ baseQuery = ItemsById(context, context.ItemValuesMap
.AsNoTracking()
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
&& ivm.ItemValue.Type == ItemValueType.Genre)
- .Select(ivm => ivm.Item);
+ .Select(ivm => ivm.ItemId));
break;
case BaseItemKind.Studio:
- baseQuery = context.ItemValuesMap
+ baseQuery = ItemsById(context, context.ItemValuesMap
.AsNoTracking()
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
&& ivm.ItemValue.Type == ItemValueType.Studios)
- .Select(ivm => ivm.Item);
+ .Select(ivm => ivm.ItemId));
break;
case BaseItemKind.Year:
if (int.TryParse(item.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year))
@@ -254,6 +254,9 @@ public class ItemCountService : IItemCountService
return result;
}
+ private static IQueryable<BaseItemEntity> ItemsById(JellyfinDbContext context, IQueryable<Guid> itemIds)
+ => context.BaseItems.AsNoTracking().Where(e => itemIds.Contains(e.Id));
+
/// <inheritdoc/>
public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId)
{
@@ -293,7 +296,8 @@ public class ItemCountService : IItemCountService
var allDescendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, parentId);
var baseQuery = dbContext.BaseItems
- .Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem);
+ .Where(b => allDescendantIds.Contains(b.Id))
+ .Where(DescendantQueryHelper.IsCountableLeaf);
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id);
@@ -314,13 +318,14 @@ 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);
@@ -354,7 +359,7 @@ public class ItemCountService : IItemCountService
var userId = user.Id;
var leafItems = dbContext.BaseItems
- .Where(b => !b.IsFolder && !b.IsVirtualItem);
+ .Where(DescendantQueryHelper.IsCountableLeaf);
leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter);
var playedLeafItems = leafItems
diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
index b10f7c527e..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
@@ -428,106 +435,144 @@ public class ItemPersistenceService : IItemPersistenceService
foreach (var item in tuples)
{
- if (item.Item is Folder folder)
+ // A container that was never hydrated cannot be used to rewrite its links: its empty
+ // array means "unknown", so clearing the stored rows would silently empty the item.
+ if (item.Item is Folder { LinkedChildrenLoaded: false })
+ {
+ continue;
+ }
+
+ if (item.Item is Folder or Video
+ && allLinkedChildrenByParent.TryGetValue(item.Item.Id, out var existingLinks)
+ && existingLinks.Count > 0)
{
- var existingLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(item.Item.Id)?.ToList() ?? new List<LinkedChildEntity>();
- if (folder.LinkedChildren.Length > 0)
+ // A video only owns its alternate version links; any other link on that parent is
+ // written by the folder branch below and must survive.
+ var staleLinks = item.Item is Folder
+ ? existingLinks
+ : existingLinks
+ .Where(e => e.ChildType is DbLinkedChildType.LocalAlternateVersion or DbLinkedChildType.LinkedAlternateVersion)
+ .ToList();
+
+ if (staleLinks.Count > 0)
{
+ context.LinkedChildren.RemoveRange(staleLinks);
+ }
+ }
+ }
+
+ context.SaveChanges();
+
+ // A LinkedChild's ItemId is only a cache.
+ var cachedChildIds = tuples
+ .Select(t => t.Item)
+ .OfType<Folder>()
+ .Where(f => f.LinkedChildrenLoaded)
+ .SelectMany(f => f.LinkedChildren)
+ .Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty())
+ .Select(lc => lc.ItemId!.Value)
+ .Distinct()
+ .ToList();
+
+ var knownChildIds = cachedChildIds.Count > 0
+ ? context.BaseItems
+ .WhereOneOrMany(cachedChildIds, e => e.Id)
+ .Select(e => e.Id)
+ .ToHashSet()
+ : [];
+
+ foreach (var item in tuples)
+ {
+ if (item.Item is Folder { LinkedChildrenLoaded: true } folder && folder.LinkedChildren.Length > 0)
+ {
#pragma warning disable CS0618 // Type or member is obsolete - legacy path resolution for old data
- var pathsToResolve = folder.LinkedChildren
- .Where(lc => (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty()) && !string.IsNullOrEmpty(lc.Path))
- .Select(lc => lc.Path)
- .Distinct()
- .ToList();
+ var pathsToResolve = folder.LinkedChildren
+ .Where(lc => !string.IsNullOrEmpty(lc.Path)
+ && (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty() || !knownChildIds.Contains(lc.ItemId.Value)))
+ .Select(lc => lc.Path)
+ .Distinct()
+ .ToList();
- var pathToIdMap = pathsToResolve.Count > 0
- ? context.BaseItems
- .Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
- .Select(e => new { e.Path, e.Id })
- .GroupBy(e => e.Path!)
- .ToDictionary(g => g.Key, g => g.First().Id)
- : [];
+ var pathToIdMap = pathsToResolve.Count > 0
+ ? context.BaseItems
+ .Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
+ .Select(e => new { e.Path, e.Id })
+ .GroupBy(e => e.Path!)
+ .ToDictionary(g => g.Key, g => g.First().Id)
+ : [];
- var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>();
- foreach (var linkedChild in folder.LinkedChildren)
+ var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>();
+ foreach (var linkedChild in folder.LinkedChildren)
+ {
+ var childItemId = linkedChild.ItemId;
+ if (!childItemId.HasValue || childItemId.Value.IsEmpty() || !knownChildIds.Contains(childItemId.Value))
{
- var childItemId = linkedChild.ItemId;
- if (!childItemId.HasValue || childItemId.Value.IsEmpty())
+ if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId))
{
- if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId))
- {
- childItemId = resolvedId;
- }
+ childItemId = resolvedId;
}
-#pragma warning restore CS0618
-
- if (childItemId.HasValue && !childItemId.Value.IsEmpty())
+ else if (Guid.TryParse(linkedChild.LibraryItemId, out var libraryItemId) && !libraryItemId.IsEmpty())
{
- resolvedChildren.Add((linkedChild, childItemId.Value));
+ childItemId = libraryItemId;
}
}
+#pragma warning restore CS0618
+ if (childItemId.HasValue && !childItemId.Value.IsEmpty())
+ {
+ resolvedChildren.Add((linkedChild, childItemId.Value));
+ }
+ }
+
+ // Playlists may legitimately contain the same item multiple times (e.g. a song repeated
+ // in an .m3u file). Every other container type keeps a single entry per child.
+ var isPlaylist = folder is Playlist;
+ if (!isPlaylist)
+ {
resolvedChildren = resolvedChildren
.GroupBy(c => c.ChildId)
.Select(g => g.Last())
.ToList();
+ }
- var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).ToList();
- var existingChildIds = childIdsToCheck.Count > 0
- ? context.BaseItems
- .Where(e => childIdsToCheck.Contains(e.Id))
- .Select(e => e.Id)
- .ToHashSet()
- : [];
-
- var isPlaylist = folder is Playlist;
- var sortOrder = 0;
- foreach (var (linkedChild, childId) in resolvedChildren)
- {
- if (!existingChildIds.Contains(childId))
- {
- _logger.LogWarning(
- "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} does not exist in database",
- item.Item.Name,
- item.Item.Id,
- childId);
- continue;
- }
-
- var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId);
- if (existingLink is null)
- {
- context.LinkedChildren.Add(new LinkedChildEntity()
- {
- ParentId = item.Item.Id,
- ChildId = childId,
- ChildType = (DbLinkedChildType)linkedChild.Type,
- SortOrder = isPlaylist ? sortOrder : null
- });
- }
- else
- {
- existingLink.SortOrder = isPlaylist ? sortOrder : null;
- existingLink.ChildType = (DbLinkedChildType)linkedChild.Type;
- existingLinkedChildren.Remove(existingLink);
- }
+ var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList();
+ var existingChildIds = childIdsToCheck.Count > 0
+ ? context.BaseItems
+ .WhereOneOrMany(childIdsToCheck, e => e.Id)
+ .Select(e => e.Id)
+ .ToHashSet()
+ : [];
- sortOrder++;
+ var sortOrder = 0;
+ foreach (var (linkedChild, childId) in resolvedChildren)
+ {
+ if (!existingChildIds.Contains(childId))
+ {
+#pragma warning disable CS0618 // Type or member is obsolete - legacy path is logged for diagnostics
+ _logger.LogWarning(
+ "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} (path {ChildPath}) does not exist in database",
+ item.Item.Name,
+ item.Item.Id,
+ childId,
+ linkedChild.Path ?? "unknown");
+#pragma warning restore CS0618
+ continue;
}
- }
- if (existingLinkedChildren.Count > 0)
- {
- context.LinkedChildren.RemoveRange(existingLinkedChildren);
+ context.LinkedChildren.Add(new LinkedChildEntity()
+ {
+ ParentId = item.Item.Id,
+ ChildId = childId,
+ ChildType = (DbLinkedChildType)linkedChild.Type,
+ SortOrder = sortOrder
+ });
+
+ sortOrder++;
}
}
if (item.Item is Video video)
{
- var existingLinkedChildren = (allLinkedChildrenByParent.GetValueOrDefault(video.Id) ?? new List<LinkedChildEntity>())
- .Where(e => (int)e.ChildType == 2 || (int)e.ChildType == 3)
- .ToList();
-
var newLinkedChildren = new List<(Guid ChildId, LinkedChildType Type)>();
if (video.LocalAlternateVersions.Length > 0)
@@ -577,7 +622,7 @@ public class ItemPersistenceService : IItemPersistenceService
.ToHashSet()
: [];
- int sortOrder = 0;
+ var sortOrder = 0;
foreach (var (childId, childType) in newLinkedChildren)
{
if (!existingChildIds.Contains(childId))
@@ -590,36 +635,27 @@ public class ItemPersistenceService : IItemPersistenceService
continue;
}
- var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId);
- if (existingLink is null)
+ context.LinkedChildren.Add(new LinkedChildEntity
{
- context.LinkedChildren.Add(new LinkedChildEntity
- {
- ParentId = video.Id,
- ChildId = childId,
- ChildType = (DbLinkedChildType)childType,
- SortOrder = sortOrder
- });
- }
- else
- {
- existingLink.ChildType = (DbLinkedChildType)childType;
- existingLink.SortOrder = sortOrder;
- existingLinkedChildren.Remove(existingLink);
- }
+ ParentId = video.Id,
+ ChildId = childId,
+ ChildType = (DbLinkedChildType)childType,
+ SortOrder = sortOrder
+ });
sortOrder++;
}
- if (existingLinkedChildren.Count > 0)
+ // A previously-linked LocalAlternateVersion that is no longer present becomes orphaned;
+ var previousLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(video.Id);
+ if (previousLinkedChildren is { Count: > 0 })
{
- var orphanedLocalVersionIds = existingLinkedChildren
- .Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion)
+ var newChildIds = newLinkedChildren.Select(c => c.ChildId).ToHashSet();
+ var orphanedLocalVersionIds = previousLinkedChildren
+ .Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion && !newChildIds.Contains(e.ChildId))
.Select(e => e.ChildId)
.ToList();
- context.LinkedChildren.RemoveRange(existingLinkedChildren);
-
if (orphanedLocalVersionIds.Count > 0)
{
var orphanedItems = context.BaseItems
diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
index 5e5ce320a5..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;
}
}
@@ -159,12 +180,16 @@ public class LinkedChildrenService : ILinkedChildrenService
if (existingLink is null)
{
+ var nextSortOrder = (context.LinkedChildren
+ .Where(lc => lc.ParentId == parentId)
+ .Max(lc => (int?)lc.SortOrder) ?? -1) + 1;
+
context.LinkedChildren.Add(new Jellyfin.Database.Implementations.Entities.LinkedChildEntity
{
ParentId = parentId,
ChildId = childId,
ChildType = dbChildType,
- SortOrder = null
+ SortOrder = nextSortOrder
});
}
else
diff --git a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
index 7fa33c8639..a25629132b 100644
--- a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
@@ -172,8 +172,7 @@ public class MediaStreamRepository : IMediaStreamRepository
if (!string.IsNullOrEmpty(dto.Language))
{
- var culture = _localization.FindLanguageInfo(dto.Language);
- dto.LocalizedLanguage = culture?.DisplayName;
+ dto.LocalizedLanguage = _localization.GetLanguageDisplayName(dto.Language);
}
if (dto.Type is MediaStreamType.Audio)
diff --git a/Jellyfin.Server.Implementations/Item/NextUpService.cs b/Jellyfin.Server.Implementations/Item/NextUpService.cs
index 725b4cfaac..f478daef23 100644
--- a/Jellyfin.Server.Implementations/Item/NextUpService.cs
+++ b/Jellyfin.Server.Implementations/Item/NextUpService.cs
@@ -98,7 +98,7 @@ public class NextUpService : INextUpService
.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
lastWatchedBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedBase, filter);
- // Use lightweight projection + client-side grouping to avoid correlated scalar subquery
+ // Use lightweight projection + client-side dedup to avoid the correlated scalar subquery
// per group that EF generates for GroupBy+OrderByDescending+FirstOrDefault.
var allPlayedLite = lastWatchedBase
.Select(e => new
@@ -110,15 +110,11 @@ public class NextUpService : INextUpService
})
.ToList();
- var lastWatchedInfo = new Dictionary<string, Guid>();
- foreach (var group in allPlayedLite.GroupBy(e => e.SeriesPresentationUniqueKey))
- {
- var lastWatched = group
- .OrderByDescending(e => e.ParentIndexNumber)
- .ThenByDescending(e => e.IndexNumber)
- .First();
- lastWatchedInfo[group.Key!] = lastWatched.Id;
- }
+ var lastWatchedInfo = allPlayedLite
+ .OrderByDescending(e => e.ParentIndexNumber)
+ .ThenByDescending(e => e.IndexNumber)
+ .DistinctBy(e => e.SeriesPresentationUniqueKey)
+ .ToDictionary(e => e.SeriesPresentationUniqueKey!, e => e.Id);
Dictionary<string, Guid> lastWatchedByDateInfo = new();
if (includeWatchedForRewatching)
@@ -144,11 +140,10 @@ public class NextUpService : INextUpService
(e, ud) => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate })
.ToList();
- foreach (var group in playedWithDates.GroupBy(x => x.SeriesPresentationUniqueKey))
- {
- var mostRecent = group.OrderByDescending(x => x.LastPlayedDate).First();
- lastWatchedByDateInfo[group.Key!] = mostRecent.EpisodeId;
- }
+ lastWatchedByDateInfo = playedWithDates
+ .OrderByDescending(x => x.LastPlayedDate)
+ .DistinctBy(x => x.SeriesPresentationUniqueKey)
+ .ToDictionary(x => x.SeriesPresentationUniqueKey!, x => x.EpisodeId);
}
var allLastWatchedIds = lastWatchedInfo.Values
diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
index aac85d0131..00b10e44a9 100644
--- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs
+++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
@@ -29,19 +29,30 @@ public static class OrderMapper
/// <returns>Func to be executed later for sorting query.</returns>
public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query, JellyfinDbContext jellyfinDbContext)
{
+ if (sortBy == ItemSortBy.DatePlayed)
+ {
+ // An item's played date is the newest of its own progress and that of its alternate versions,
+ // which track progress under their own ids. Matching both in one predicate ORs them together,
+ // which no index can serve: the user's whole UserData table gets scanned per sorted row.
+ // Two indexed lookups combined by MAX cost a seek each instead.
+ var userData = query.User is null
+ ? jellyfinDbContext.UserData
+ : jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id);
+
+ return e => userData
+ .Where(w => w.ItemId == e.Id)
+ .Select(w => w.LastPlayedDate)
+ .Concat(userData
+ .Where(w => w.Item!.PrimaryVersionId == e.Id)
+ .Select(w => w.LastPlayedDate))
+ .Max();
+ }
+
return (sortBy, query.User) switch
{
(ItemSortBy.AirTime, _) => e => e.SortName,
(ItemSortBy.Runtime, _) => e => e.RunTimeTicks,
(ItemSortBy.Random, _) => e => EF.Functions.Random(),
- (ItemSortBy.DatePlayed, not null) => e =>
- jellyfinDbContext.UserData
- .Where(w => w.UserId == query.User.Id && (w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id))
- .Max(f => f.LastPlayedDate),
- (ItemSortBy.DatePlayed, null) => e =>
- jellyfinDbContext.UserData
- .Where(w => w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id)
- .Max(f => f.LastPlayedDate),
(ItemSortBy.PlayCount, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.PlayCount,
(ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).Select(f => (bool?)f.IsFavorite).FirstOrDefault() ?? false,
(ItemSortBy.IsFolder, _) => e => e.IsFolder,
@@ -57,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 eb87b525fe..a592d0e6e2 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(),
};
}
@@ -79,7 +89,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
public IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter)
{
using var context = _dbProvider.CreateDbContext();
- var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter).Select(e => e.Name).Distinct();
+
+ IQueryable<string> dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter)
+ .Select(e => e.Name)
+ .Distinct()
+ .OrderBy(e => e);
if (filter.StartIndex.HasValue && filter.StartIndex > 0)
{
@@ -88,7 +102,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (filter.Limit > 0)
{
- dbQuery = dbQuery.OrderBy(e => e).Take(filter.Limit);
+ dbQuery = dbQuery.Take(filter.Limit);
}
return dbQuery.ToArray();
@@ -103,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();
@@ -118,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()
{
@@ -143,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++;
}
@@ -201,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,
@@ -236,13 +328,25 @@ 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())
diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs
index 13c7895f83..0989ce84ba 100644
--- a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs
+++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs
@@ -87,8 +87,9 @@ public static class StorageHelper
/// </summary>
private static string ResolvePath(string path)
{
- var parts = path.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
- var current = Path.DirectorySeparatorChar.ToString();
+ var root = Path.GetPathRoot(path) ?? Path.DirectorySeparatorChar.ToString();
+ var parts = path.Substring(root.Length).Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
+ var current = root;
foreach (var part in parts)
{
current = Path.Combine(current, part);
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index 41648268a9..fea6084267 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -225,17 +225,8 @@ namespace Jellyfin.Server.Implementations.Users
?? throw new ResourceNotFoundException(nameof(user.Id));
dbContext.Entry(dbUser).CurrentValues.SetValues(user);
- dbUser.Permissions.Clear();
- foreach (var permission in user.Permissions)
- {
- dbUser.Permissions.Add(new Permission(permission.Kind, permission.Value));
- }
-
- dbUser.Preferences.Clear();
- foreach (var preference in user.Preferences)
- {
- dbUser.Preferences.Add(new Preference(preference.Kind, preference.Value));
- }
+ SyncPermissions(dbUser, user.Permissions);
+ SyncPreferences(dbUser, user.Preferences);
dbUser.AccessSchedules.Clear();
foreach (var accessSchedule in user.AccessSchedules)
@@ -269,6 +260,60 @@ namespace Jellyfin.Server.Implementations.Users
}
}
+ private static void SyncPermissions(User dbUser, ICollection<Permission> source)
+ {
+ var incoming = new Dictionary<PermissionKind, bool>();
+ foreach (var permission in source)
+ {
+ incoming[permission.Kind] = permission.Value;
+ }
+
+ foreach (var existing in dbUser.Permissions)
+ {
+ if (incoming.Remove(existing.Kind, out var value))
+ {
+ // EF only marks the row modified if the value actually differs, so an update that
+ // touches nothing but the user row - a session activity stamp - writes no children.
+ existing.Value = value;
+ }
+ else
+ {
+ dbUser.Permissions.Remove(existing);
+ }
+ }
+
+ foreach (var (kind, value) in incoming)
+ {
+ dbUser.Permissions.Add(new Permission(kind, value));
+ }
+ }
+
+ private static void SyncPreferences(User dbUser, ICollection<Preference> source)
+ {
+ var incoming = new Dictionary<PreferenceKind, string>();
+ foreach (var preference in source)
+ {
+ incoming[preference.Kind] = preference.Value;
+ }
+
+ foreach (var existing in dbUser.Preferences)
+ {
+ if (incoming.Remove(existing.Kind, out var value))
+ {
+ existing.Value = value;
+ }
+ else
+ {
+ dbUser.Preferences.Remove(existing);
+ }
+ }
+
+ foreach (var (kind, value) in incoming)
+ {
+ dbUser.Preferences.Add(new Preference(kind, value));
+ }
+ }
+
internal async Task<User> CreateUserInternalAsync(string name, JellyfinDbContext dbContext)
{
// TODO: Remove after user item data is migrated.
@@ -911,7 +956,7 @@ namespace Jellyfin.Server.Implementations.Users
internal static void ThrowIfInvalidUsername(string name)
{
- if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name))
+ if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name) && !string.Equals(name, ".", StringComparison.Ordinal) && !string.Equals(name, "..", StringComparison.Ordinal))
{
return;
}