diff options
| author | Shadowghost <Ghost_of_Stone@web.de> | 2026-01-17 17:10:07 +0100 |
|---|---|---|
| committer | Shadowghost <Ghost_of_Stone@web.de> | 2026-01-18 19:48:46 +0100 |
| commit | 5996c4afce11249804d24f1caa3a99b390543c4d (patch) | |
| tree | d84b98428d95c801492b1354571e2ab3fc0cc99b /Jellyfin.Server.Implementations | |
| parent | dfa78590c2899c7e74b142ebbced4140a354aed0 (diff) | |
Complete LinkedChildren integration and batch DTO optimizations
This commit integrates remaining performance changes:
- Add batch user data fetching in DtoService to reduce N+1 queries
- Add GetNextUpEpisodesBatch in TVSeriesManager for efficient batch retrieval
- Update Video/Movie/BoxSet to use LibraryManager for alternate versions
- Transition LinkedChild to use ItemId instead of Path (obsolete Path/LibraryItemId)
- Update providers and controllers for LinkedChildren-based references
- Add NextUpEpisodeBatchResult for batched episode queries
- Integrate IDescendantQueryProvider in SqliteDatabaseProvider
Diffstat (limited to 'Jellyfin.Server.Implementations')
| -rw-r--r-- | Jellyfin.Server.Implementations/Item/BaseItemRepository.cs | 1608 |
1 files changed, 1340 insertions, 268 deletions
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index 110b6b5faf..bfaaa4b24a 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -1,6 +1,7 @@ #pragma warning disable RS0030 // Do not use banned APIs // Do not enforce that because EFCore cannot deal with cultures well. #pragma warning disable CA1304 // Specify CultureInfo +#pragma warning disable CA1309 // Use ordinal string comparison #pragma warning disable CA1311 // Specify a culture or use an invariant version #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons @@ -19,6 +20,7 @@ using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Database.Implementations.MatchCriteria; using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using Jellyfin.Server.Implementations.Extensions; @@ -31,6 +33,7 @@ using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Playlists; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.LiveTv; @@ -39,6 +42,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem; using BaseItemEntity = Jellyfin.Database.Implementations.Entities.BaseItemEntity; +using DbLinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; +using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType; namespace Jellyfin.Server.Implementations.Item; @@ -69,6 +74,7 @@ public sealed class BaseItemRepository private readonly IItemTypeLookup _itemTypeLookup; private readonly IServerConfigurationManager _serverConfigurationManager; private readonly ILogger<BaseItemRepository> _logger; + private readonly IDescendantQueryProvider _descendantQueryProvider; private static readonly IReadOnlyList<ItemValueType> _getAllArtistsValueTypes = [ItemValueType.Artist, ItemValueType.AlbumArtist]; private static readonly IReadOnlyList<ItemValueType> _getArtistValueTypes = [ItemValueType.Artist]; @@ -77,6 +83,10 @@ public sealed class BaseItemRepository private static readonly IReadOnlyList<ItemValueType> _getGenreValueTypes = [ItemValueType.Genre]; private static readonly IReadOnlyList<char> SearchWildcardTerms = ['%', '_', '[', ']', '^']; + private static readonly string ImdbProviderName = MetadataProvider.Imdb.ToString().ToLowerInvariant(); + private static readonly string TmdbProviderName = MetadataProvider.Tmdb.ToString().ToLowerInvariant(); + private static readonly string TvdbProviderName = MetadataProvider.Tvdb.ToString().ToLowerInvariant(); + /// <summary> /// Initializes a new instance of the <see cref="BaseItemRepository"/> class. /// </summary> @@ -85,18 +95,21 @@ public sealed class BaseItemRepository /// <param name="itemTypeLookup">The static type lookup.</param> /// <param name="serverConfigurationManager">The server Configuration manager.</param> /// <param name="logger">System logger.</param> + /// <param name="databaseProvider">The database provider for database-specific operations.</param> public BaseItemRepository( IDbContextFactory<JellyfinDbContext> dbProvider, IServerApplicationHost appHost, IItemTypeLookup itemTypeLookup, IServerConfigurationManager serverConfigurationManager, - ILogger<BaseItemRepository> logger) + ILogger<BaseItemRepository> logger, + IJellyfinDatabaseProvider databaseProvider) { _dbProvider = dbProvider; _appHost = appHost; _itemTypeLookup = itemTypeLookup; _serverConfigurationManager = serverConfigurationManager; _logger = logger; + _descendantQueryProvider = databaseProvider.DescendantQueryProvider; } /// <inheritdoc /> @@ -112,7 +125,23 @@ public sealed class BaseItemRepository var date = (DateTime?)DateTime.UtcNow; - var relatedItems = ids.SelectMany(f => TraverseHirachyDown(f, context)).ToArray(); + var descendantIds = ids.SelectMany(f => _descendantQueryProvider.GetAllDescendantIds(context, f)).ToHashSet(); + foreach (var id in ids) + { + descendantIds.Add(id); + } + + var extraIds = context.BaseItems + .Where(e => e.OwnerId.HasValue && descendantIds.Contains(e.OwnerId.Value)) + .Select(e => e.Id) + .ToArray(); + + foreach (var extraId in extraIds) + { + descendantIds.Add(extraId); + } + + var relatedItems = descendantIds.ToArray(); // Remove any UserData entries for the placeholder item that would conflict with the UserData // being detached from the item being deleted. This is necessary because, during an update, @@ -140,12 +169,14 @@ public sealed class BaseItemRepository context.BaseItemMetadataFields.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.BaseItemProviders.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.BaseItemTrailerTypes.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); - context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDelete(); context.Chapters.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.CustomItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.ItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDelete(); context.ItemValuesMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ParentId).ExecuteDelete(); + context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ChildId).ExecuteDelete(); + context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDelete(); context.KeyframeData.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.MediaSegments.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.MediaStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); @@ -250,7 +281,7 @@ public sealed class BaseItemRepository public QueryResult<BaseItemDto> GetItems(InternalItemsQuery filter) { ArgumentNullException.ThrowIfNull(filter); - if (!filter.EnableTotalRecordCount || ((filter.Limit ?? 0) == 0 && (filter.StartIndex ?? 0) == 0)) + if (!filter.EnableTotalRecordCount || (!filter.Limit.HasValue && (filter.StartIndex ?? 0) == 0)) { var returnList = GetItemList(filter); return new QueryResult<BaseItemDto>( @@ -301,47 +332,276 @@ public sealed class BaseItemRepository } /// <inheritdoc/> - public IReadOnlyList<BaseItem> GetLatestItemList(InternalItemsQuery filter, CollectionType collectionType) + public IReadOnlyList<BaseItemDto> GetLatestItemList(InternalItemsQuery filter, CollectionType collectionType) { ArgumentNullException.ThrowIfNull(filter); PrepareFilterQuery(filter); - // Early exit if collection type is not tvshows or music - if (collectionType != CollectionType.tvshows && collectionType != CollectionType.music) + // Early exit if collection type is not supported + if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music) { - return Array.Empty<BaseItem>(); + return []; } + var limit = filter.Limit ?? 50; using var context = _dbProvider.CreateDbContext(); - // Subquery to group by SeriesNames/Album and get the max Date Created for each group. - var subquery = PrepareItemQuery(context, filter); - subquery = TranslateQuery(subquery, context, filter); - var subqueryGrouped = subquery.GroupBy(g => collectionType == CollectionType.tvshows ? g.SeriesName : g.Album) - .Select(g => new + var baseQuery = PrepareItemQuery(context, filter); + baseQuery = TranslateQuery(baseQuery, context, filter); + + if (collectionType == CollectionType.tvshows) + { + return GetLatestTvShowItems(context, baseQuery, filter, limit); + } + + // Determine the grouping key selector based on collection type + // Movies: PresentationUniqueKey (groups alternate versions like 4K/1080p) + // Music: Album + Func<BaseItemEntity, string?> groupKeySelector = collectionType switch + { + CollectionType.movies => e => e.PresentationUniqueKey, + _ => e => e.Album + }; + + // EF Core requires compile-time expressions, so we use conditional queries + var topGroupKeys = collectionType switch + { + CollectionType.movies => baseQuery + .Where(e => e.PresentationUniqueKey != null) + .GroupBy(e => e.PresentationUniqueKey) + .Select(g => new { GroupKey = g.Key!, MaxDate = g.Max(e => e.DateCreated) }) + .OrderByDescending(g => g.MaxDate) + .Take(limit) + .Select(g => g.GroupKey) + .ToList(), + _ => baseQuery + .Where(e => e.Album != null) + .GroupBy(e => e.Album) + .Select(g => new { GroupKey = g.Key!, MaxDate = g.Max(e => e.DateCreated) }) + .OrderByDescending(g => g.MaxDate) + .Take(limit) + .Select(g => g.GroupKey) + .ToList() + }; + + if (topGroupKeys.Count == 0) + { + return []; + } + + var itemsQuery = collectionType switch + { + CollectionType.movies => baseQuery.Where(e => e.PresentationUniqueKey != null && topGroupKeys.Contains(e.PresentationUniqueKey)), + _ => baseQuery.Where(e => e.Album != null && topGroupKeys.Contains(e.Album)) + }; + + itemsQuery = itemsQuery.OrderByDescending(e => e.DateCreated).ThenByDescending(e => e.Id); + itemsQuery = ApplyNavigations(itemsQuery, filter).AsSingleQuery(); + + var allItems = itemsQuery.ToList(); + + var latestItems = allItems + .GroupBy(groupKeySelector) + .Select(g => g.First()) + .OrderByDescending(e => e.DateCreated) + .ThenByDescending(e => e.Id) + .ToList(); + + return latestItems + .Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)) + .Where(dto => dto is not null) + .ToArray()!; + } + + /// <summary> + /// Gets the latest TV show items with smart Season/Series container selection. + /// </summary> + /// <remarks> + /// If multiple episodes were recently added to a single season, returns the Season. + /// If episodes span multiple seasons, returns the Series. + /// If only a single episode was added, returns the Episode. + /// </remarks> + private IReadOnlyList<BaseItemDto> GetLatestTvShowItems(JellyfinDbContext context, IQueryable<BaseItemEntity> baseQuery, InternalItemsQuery filter, int limit) + { + const int WindowDays = 7; + const int MaxWindowDays = 84; + const double RecentAdditionWindowHours = 24.0; + + var now = DateTime.UtcNow; + + List<string> topSeriesNames = []; + for (int days = WindowDays; days <= MaxWindowDays && topSeriesNames.Count < limit; days += WindowDays) + { + var cutoff = now.AddDays(-days); + topSeriesNames = baseQuery + .Where(e => e.SeriesName != null && e.DateCreated >= cutoff) + .GroupBy(e => e.SeriesName) + .OrderByDescending(g => g.Max(e => e.DateCreated)) + .Take(limit) + .Select(g => g.Key!) + .ToList(); + } + + // Fallback without date filter if needed + if (topSeriesNames.Count < limit) + { + topSeriesNames = baseQuery + .Where(e => e.SeriesName != null) + .GroupBy(e => e.SeriesName) + .OrderByDescending(g => g.Max(e => e.DateCreated)) + .Take(limit) + .Select(g => g.Key!) + .ToList(); + } + + if (topSeriesNames.Count == 0) + { + return []; + } + + // Get all episodes from identified series with navigations + var allEpisodes = ApplyNavigations( + baseQuery + .Where(e => e.SeriesName != null && topSeriesNames.Contains(e.SeriesName)) + .OrderByDescending(e => e.DateCreated) + .ThenByDescending(e => e.Id), + filter) + .AsSingleQuery() + .ToList(); + + var allSeasonIds = new HashSet<Guid>(); + var allSeriesIds = new HashSet<Guid>(); + var analysisData = new List<( + List<BaseItemEntity> RecentEpisodes, + List<Guid> SeasonIds, + DateTime MaxDate, + BaseItemEntity MostRecentEpisode)>(); + + foreach (var group in allEpisodes.GroupBy(e => e.SeriesName)) + { + var episodes = group.ToList(); + var mostRecentDate = episodes[0].DateCreated ?? DateTime.MinValue; + var recentCutoff = mostRecentDate.AddHours(-RecentAdditionWindowHours); + + var recentEpisodes = new List<BaseItemEntity>(); + var seasonIdSet = new HashSet<Guid>(); + + foreach (var ep in episodes) { - Key = g.Key, - MaxDateCreated = g.Max(a => a.DateCreated) - }) - .OrderByDescending(g => g.MaxDateCreated) - .Select(g => g); + if (ep.DateCreated >= recentCutoff) + { + recentEpisodes.Add(ep); + if (ep.SeasonId.HasValue) + { + seasonIdSet.Add(ep.SeasonId.Value); + } + } + } + + var seasonIds = seasonIdSet.ToList(); + analysisData.Add((recentEpisodes, seasonIds, mostRecentDate, episodes[0])); + + foreach (var sid in seasonIds) + { + allSeasonIds.Add(sid); + } - if (filter.Limit.HasValue && filter.Limit.Value > 0) + if (recentEpisodes.Count > 0 && recentEpisodes[0].SeriesId.HasValue) + { + allSeriesIds.Add(recentEpisodes[0].SeriesId!.Value); + } + } + + var episodeType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; + var seasonType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Season]; + var seasonEpisodeCounts = allSeasonIds.Count > 0 + ? context.BaseItems + .AsNoTracking() + .Where(e => e.SeasonId.HasValue && allSeasonIds.Contains(e.SeasonId.Value) && e.Type == episodeType) + .GroupBy(e => e.SeasonId!.Value) + .Select(g => new { SeasonId = g.Key, Count = g.Count() }) + .ToDictionary(x => x.SeasonId, x => x.Count) + : []; + + var seriesSeasonCounts = allSeriesIds.Count > 0 + ? context.BaseItems + .AsNoTracking() + .Where(e => e.SeriesId.HasValue && allSeriesIds.Contains(e.SeriesId.Value) && e.Type == seasonType) + .GroupBy(e => e.SeriesId!.Value) + .Select(g => new { SeriesId = g.Key, Count = g.Count() }) + .ToDictionary(x => x.SeriesId, x => x.Count) + : []; + + var entitiesToFetch = new HashSet<Guid>(); + var seriesResults = new List<(Guid? SeasonId, Guid? SeriesId, DateTime MaxDate, BaseItemEntity MostRecentEpisode)>(analysisData.Count); + foreach (var (recentEpisodes, seasonIds, maxDate, mostRecentEpisode) in analysisData) { - subqueryGrouped = subqueryGrouped.Take(filter.Limit.Value); + Guid? seasonId = null; + Guid? seriesId = null; + + if (seasonIds.Count == 1) + { + var sid = seasonIds[0]; + var totalEpisodes = seasonEpisodeCounts.GetValueOrDefault(sid, 0); + var episodeSeriesId = recentEpisodes.Count > 0 ? recentEpisodes[0].SeriesId : null; + var totalSeasonsInSeries = episodeSeriesId.HasValue + ? seriesSeasonCounts.GetValueOrDefault(episodeSeriesId.Value, 1) + : 1; + + var hasMultipleOrAllEpisodes = recentEpisodes.Count > 1 || recentEpisodes.Count == totalEpisodes; + if (totalSeasonsInSeries > 1 && hasMultipleOrAllEpisodes) + { + seasonId = sid; + entitiesToFetch.Add(sid); + } + else if (hasMultipleOrAllEpisodes && episodeSeriesId.HasValue) + { + seriesId = episodeSeriesId; + entitiesToFetch.Add(episodeSeriesId.Value); + } + } + else if (seasonIds.Count > 1 && recentEpisodes.Count > 0 && recentEpisodes[0].SeriesId.HasValue) + { + seriesId = recentEpisodes[0].SeriesId; + entitiesToFetch.Add(seriesId!.Value); + } + + seriesResults.Add((seasonId, seriesId, maxDate, mostRecentEpisode)); } - filter.Limit = null; + var entities = entitiesToFetch.Count > 0 + ? ApplyNavigations( + context.BaseItems.AsNoTracking().Where(e => entitiesToFetch.Contains(e.Id)), + filter) + .AsSingleQuery() + .ToDictionary(e => e.Id) + : []; - var mainquery = PrepareItemQuery(context, filter); - mainquery = TranslateQuery(mainquery, context, filter); - mainquery = mainquery.Where(g => g.DateCreated >= subqueryGrouped.Min(s => s.MaxDateCreated)); - mainquery = ApplyGroupingFilter(context, mainquery, filter); - mainquery = ApplyQueryPaging(mainquery, filter); + var results = new List<(BaseItemEntity Entity, DateTime MaxDate)>(seriesResults.Count); + foreach (var (seasonId, seriesId, maxDate, mostRecentEpisode) in seriesResults) + { + if (seasonId.HasValue && entities.TryGetValue(seasonId.Value, out var seasonEntity)) + { + results.Add((seasonEntity, maxDate)); + continue; + } - mainquery = ApplyNavigations(mainquery, filter); + if (seriesId.HasValue && entities.TryGetValue(seriesId.Value, out var seriesEntity)) + { + results.Add((seriesEntity, maxDate)); + continue; + } - return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!; + results.Add((mostRecentEpisode, maxDate)); + } + + return results + .OrderByDescending(r => r.MaxDate) + .ThenByDescending(r => r.Entity.Id) + .Take(limit) + .Select(r => DeserializeBaseItem(r.Entity, filter.SkipDeserialization)) + .Where(dto => dto is not null) + .ToArray()!; } /// <inheritdoc /> @@ -367,7 +627,7 @@ public sealed class BaseItemRepository .OrderByDescending(g => g.LastPlayedDate) .Select(g => g.Key!); - if (filter.Limit.HasValue && filter.Limit.Value > 0) + if (filter.Limit.HasValue) { query = query.Take(filter.Limit.Value); } @@ -375,6 +635,277 @@ public sealed class BaseItemRepository return query.ToArray(); } + /// <inheritdoc /> + public IReadOnlyDictionary<string, NextUpEpisodeBatchResult> GetNextUpEpisodesBatch( + InternalItemsQuery filter, + IReadOnlyList<string> seriesKeys, + bool includeSpecials, + bool includeWatchedForRewatching) + { + ArgumentNullException.ThrowIfNull(filter); + ArgumentNullException.ThrowIfNull(filter.User); + + if (seriesKeys.Count == 0) + { + return new Dictionary<string, NextUpEpisodeBatchResult>(); + } + + PrepareFilterQuery(filter); + using var context = _dbProvider.CreateDbContext(); + + var userId = filter.User.Id; + var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; + + // Get the last watched episode ID per series (highest season/episode that is played) + var lastWatchedInfo = context.BaseItems + .AsNoTracking() + .Where(e => e.Type == episodeTypeName) + .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) + .Where(e => e.ParentIndexNumber != 0) + .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)) + .GroupBy(e => e.SeriesPresentationUniqueKey) + .Select(g => new + { + SeriesKey = g.Key!, + LastWatchedId = g.OrderByDescending(e => e.ParentIndexNumber) + .ThenByDescending(e => e.IndexNumber) + .Select(e => e.Id) + .FirstOrDefault() + }) + .ToDictionary(x => x.SeriesKey, x => x.LastWatchedId); + + Dictionary<string, Guid> lastWatchedByDateInfo = new(); + if (includeWatchedForRewatching) + { + lastWatchedByDateInfo = context.BaseItems + .AsNoTracking() + .Where(e => e.Type == episodeTypeName) + .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) + .Where(e => e.ParentIndexNumber != 0) + .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)) + .SelectMany(e => e.UserData!.Where(ud => ud.UserId == userId && ud.Played) + .Select(ud => new { Episode = e, ud.LastPlayedDate })) + .GroupBy(x => x.Episode.SeriesPresentationUniqueKey) + .Select(g => new + { + SeriesKey = g.Key!, + LastWatchedId = g.OrderByDescending(x => x.LastPlayedDate) + .Select(x => x.Episode.Id) + .FirstOrDefault() + }) + .ToDictionary(x => x.SeriesKey, x => x.LastWatchedId); + } + + var allLastWatchedIds = lastWatchedInfo.Values + .Concat(lastWatchedByDateInfo.Values) + .Where(id => id != Guid.Empty) + .Distinct() + .ToList(); + var lastWatchedEpisodes = new Dictionary<Guid, BaseItemEntity>(); + if (allLastWatchedIds.Count > 0) + { + var lwQuery = context.BaseItems.AsNoTracking().Where(e => allLastWatchedIds.Contains(e.Id)); + lwQuery = ApplyNavigations(lwQuery, filter).AsSingleQuery(); + lastWatchedEpisodes = lwQuery.ToDictionary(e => e.Id); + } + + var allNextUnwatchedCandidates = context.BaseItems + .AsNoTracking() + .Where(e => e.Type == episodeTypeName) + .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) + .Where(e => e.ParentIndexNumber != 0) + .Where(e => !e.IsVirtualItem) + .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)) + .Select(e => new + { + e.Id, + e.SeriesPresentationUniqueKey, + e.ParentIndexNumber, + EpisodeNumber = e.IndexNumber + }) + .ToList(); + + List<(Guid Id, string? SeriesKey, int? Season, int? Episode)> allNextPlayedCandidates = new(); + if (includeWatchedForRewatching) + { + allNextPlayedCandidates = context.BaseItems + .AsNoTracking() + .Where(e => e.Type == episodeTypeName) + .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) + .Where(e => e.ParentIndexNumber != 0) + .Where(e => !e.IsVirtualItem) + .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)) + .Select(e => new + { + e.Id, + e.SeriesPresentationUniqueKey, + e.ParentIndexNumber, + EpisodeNumber = e.IndexNumber + }) + .AsEnumerable() + .Select(e => (e.Id, e.SeriesPresentationUniqueKey, e.ParentIndexNumber, e.EpisodeNumber)) + .ToList(); + } + + Dictionary<string, List<BaseItemEntity>> specialsBySeriesKey = new(); + if (includeSpecials) + { + var allSpecials = context.BaseItems + .AsNoTracking() + .Where(e => e.Type == episodeTypeName) + .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) + .Where(e => e.ParentIndexNumber == 0) + .Where(e => !e.IsVirtualItem) + .ToList(); + + var specialIds = allSpecials.Select(s => s.Id).ToList(); + if (specialIds.Count > 0) + { + var specialsWithNav = context.BaseItems.AsNoTracking().Where(e => specialIds.Contains(e.Id)); + specialsWithNav = ApplyNavigations(specialsWithNav, filter).AsSingleQuery(); + var specialsDict = specialsWithNav.ToDictionary(e => e.Id); + + foreach (var special in allSpecials) + { + var key = special.SeriesPresentationUniqueKey!; + if (!specialsBySeriesKey.TryGetValue(key, out var list)) + { + list = new List<BaseItemEntity>(); + specialsBySeriesKey[key] = list; + } + + if (specialsDict.TryGetValue(special.Id, out var specialWithNav)) + { + list.Add(specialWithNav); + } + } + } + } + + var nextEpisodeIds = new HashSet<Guid>(); + var seriesNextIdMap = new Dictionary<string, Guid>(); + var seriesNextPlayedIdMap = new Dictionary<string, Guid>(); + + foreach (var seriesKey in seriesKeys) + { + var candidates = allNextUnwatchedCandidates + .Where(c => c.SeriesPresentationUniqueKey == seriesKey); + + if (lastWatchedInfo.TryGetValue(seriesKey, out var lwId) && lwId != Guid.Empty) + { + var lastWatchedEntity = lastWatchedEpisodes.GetValueOrDefault(lwId); + if (lastWatchedEntity is not null) + { + var season = lastWatchedEntity.ParentIndexNumber; + var episode = lastWatchedEntity.IndexNumber; + if (season.HasValue && episode.HasValue) + { + candidates = candidates.Where(c => + c.ParentIndexNumber > season || + (c.ParentIndexNumber == season && c.EpisodeNumber > episode)); + } + } + } + + var nextCandidate = candidates + .OrderBy(c => c.ParentIndexNumber) + .ThenBy(c => c.EpisodeNumber) + .FirstOrDefault(); + + if (nextCandidate is not null && nextCandidate.Id != Guid.Empty) + { + nextEpisodeIds.Add(nextCandidate.Id); + seriesNextIdMap[seriesKey] = nextCandidate.Id; + } + + if (includeWatchedForRewatching && lastWatchedByDateInfo.TryGetValue(seriesKey, out var lastByDateId)) + { + var lastByDateEntity = lastWatchedEpisodes.GetValueOrDefault(lastByDateId); + if (lastByDateEntity is not null) + { + var lastSeason = lastByDateEntity.ParentIndexNumber; + var lastEp = lastByDateEntity.IndexNumber; + + var playedCandidates = allNextPlayedCandidates + .Where(c => c.SeriesKey == seriesKey); + + if (lastSeason.HasValue && lastEp.HasValue) + { + playedCandidates = playedCandidates.Where(c => + c.Season > lastSeason || + (c.Season == lastSeason && c.Episode > lastEp)); + } + + var nextPlayedCandidate = playedCandidates + .OrderBy(c => c.Season) + .ThenBy(c => c.Episode) + .FirstOrDefault(); + + if (nextPlayedCandidate.Id != Guid.Empty) + { + nextEpisodeIds.Add(nextPlayedCandidate.Id); + seriesNextPlayedIdMap[seriesKey] = nextPlayedCandidate.Id; + } + } + } + } + + var nextEpisodes = new Dictionary<Guid, BaseItemEntity>(); + if (nextEpisodeIds.Count > 0) + { + var nextQuery = context.BaseItems.AsNoTracking().Where(e => nextEpisodeIds.Contains(e.Id)); + nextQuery = ApplyNavigations(nextQuery, filter).AsSingleQuery(); + nextEpisodes = nextQuery.ToDictionary(e => e.Id); + } + + var result = new Dictionary<string, NextUpEpisodeBatchResult>(); + foreach (var seriesKey in seriesKeys) + { + var batchResult = new NextUpEpisodeBatchResult(); + + if (lastWatchedInfo.TryGetValue(seriesKey, out var lwId) && lwId != Guid.Empty) + { + if (lastWatchedEpisodes.TryGetValue(lwId, out var entity)) + { + batchResult.LastWatched = DeserializeBaseItem(entity, filter.SkipDeserialization); + } + } + + if (seriesNextIdMap.TryGetValue(seriesKey, out var nextId) && nextEpisodes.TryGetValue(nextId, out var nextEntity)) + { + batchResult.NextUp = DeserializeBaseItem(nextEntity, filter.SkipDeserialization); + } + + if (includeSpecials && specialsBySeriesKey.TryGetValue(seriesKey, out var specials)) + { + batchResult.Specials = specials.Select(s => DeserializeBaseItem(s, filter.SkipDeserialization)!).ToList(); + } + else + { + batchResult.Specials = Array.Empty<BaseItemDto>(); + } + + if (includeWatchedForRewatching) + { + if (lastWatchedByDateInfo.TryGetValue(seriesKey, out var lastByDateId) && + lastWatchedEpisodes.TryGetValue(lastByDateId, out var lastByDateEntity)) + { + batchResult.LastWatchedForRewatching = DeserializeBaseItem(lastByDateEntity, filter.SkipDeserialization); + } + + if (seriesNextPlayedIdMap.TryGetValue(seriesKey, out var nextPlayedId) && + nextEpisodes.TryGetValue(nextPlayedId, out var nextPlayedEntity)) + { + batchResult.NextPlayedForRewatching = DeserializeBaseItem(nextPlayedEntity, filter.SkipDeserialization); + } + } + + result[seriesKey] = batchResult; + } + + return result; + } + private IQueryable<BaseItemEntity> ApplyGroupingFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) { // This whole block is needed to filter duplicate entries on request @@ -435,19 +966,44 @@ public sealed class BaseItemRepository dbQuery = dbQuery.Include(e => e.Images); } + // Only include LinkedChildEntities for container types and videos that use them + // (BoxSet, Playlist, CollectionFolder for manual linking; Video, Movie for alternate versions) + var linkedChildTypes = new[] + { + BaseItemKind.BoxSet, + BaseItemKind.Playlist, + BaseItemKind.CollectionFolder, + BaseItemKind.Video, + BaseItemKind.Movie + }; + if (filter.IncludeItemTypes.Length == 0 || filter.IncludeItemTypes.Any(linkedChildTypes.Contains)) + { + dbQuery = dbQuery.Include(e => e.LinkedChildEntities); + } + + if (filter.IncludeExtras) + { + dbQuery = dbQuery.Include(e => e.Extras); + } + return dbQuery; } private IQueryable<BaseItemEntity> ApplyQueryPaging(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) { - if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0) + if (filter.Limit.HasValue || filter.StartIndex.HasValue) { - dbQuery = dbQuery.Skip(filter.StartIndex.Value); - } + var offset = filter.StartIndex ?? 0; - if (filter.Limit.HasValue && filter.Limit.Value > 0) - { - dbQuery = dbQuery.Take(filter.Limit.Value); + if (offset > 0) + { + dbQuery = dbQuery.Skip(offset); + } + + if (filter.Limit.HasValue) + { + dbQuery = dbQuery.Take(filter.Limit.Value); + } } return dbQuery; @@ -499,7 +1055,10 @@ public sealed class BaseItemRepository .ToArray(); var lookup = _itemTypeLookup.BaseItemKindNames; - var result = new ItemCounts(); + var result = new ItemCounts + { + ItemCount = counts.Sum(c => c.Count) + }; foreach (var count in counts) { if (string.Equals(count.Key, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal)) @@ -538,6 +1097,14 @@ public sealed class BaseItemRepository { result.TrailerCount = count.Count; } + else if (string.Equals(count.Key, lookup[BaseItemKind.BoxSet], StringComparison.Ordinal)) + { + result.BoxSetCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.Book], StringComparison.Ordinal)) + { + result.BookCount = count.Count; + } } return result; @@ -561,8 +1128,36 @@ public sealed class BaseItemRepository .FirstOrDefault(t => t is not null)); } + /// <summary> + /// Saves image information for an item. + /// </summary> + /// <param name="item">The item DTO containing image info.</param> + public void SaveImages(BaseItemDto item) + { + ArgumentNullException.ThrowIfNull(item); + + var images = item.ImageInfos.Select(e => Map(item.Id, e)); + using var context = _dbProvider.CreateDbContext(); + + if (!context.BaseItems.Any(bi => bi.Id == item.Id)) + { + _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem"); + return; + } + + context.BaseItemImageInfos.Where(e => e.ItemId == item.Id).ExecuteDelete(); + context.BaseItemImageInfos.AddRange(images); + context.SaveChanges(); + } + /// <inheritdoc /> - public async Task SaveImagesAsync(BaseItemDto item, CancellationToken cancellationToken = default) + public void SaveItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken) + { + UpdateOrInsertItems(items, cancellationToken); + } + + /// <inheritdoc /> + public async Task SaveImagesAsync(BaseItem item, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(item); @@ -592,12 +1187,6 @@ public sealed class BaseItemRepository } } - /// <inheritdoc /> - public void SaveItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken) - { - UpdateOrInsertItems(items, cancellationToken); - } - /// <inheritdoc cref="IItemRepository"/> public void UpdateOrInsertItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken) { @@ -746,13 +1335,195 @@ public sealed class BaseItemRepository context.AncestorIds.RemoveRange(existingAncestorIds); } + + if (item.Item is Folder folder) + { + var existingLinkedChildren = context.LinkedChildren.Where(e => e.ParentId == item.Item.Id).ToList(); + if (folder.LinkedChildren.Length > 0) + { +#pragma warning disable CS0618 // Type or member is obsolete - legacy path resolution for old data + var pathsToResolve = folder.LinkedChildren + .Where(lc => (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty()) && !string.IsNullOrEmpty(lc.Path)) + .Select(lc => lc.Path) + .Distinct() + .ToList(); + + var pathToIdMap = pathsToResolve.Count > 0 + ? context.BaseItems + .Where(e => e.Path != null && pathsToResolve.Contains(e.Path)) + .Select(e => new { e.Path, e.Id }) + .ToDictionary(e => e.Path!, e => e.Id) + : []; + + var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>(); + foreach (var linkedChild in folder.LinkedChildren) + { + var childItemId = linkedChild.ItemId; + if (!childItemId.HasValue || childItemId.Value.IsEmpty()) + { + if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId)) + { + childItemId = resolvedId; + } + } +#pragma warning restore CS0618 + + if (childItemId.HasValue && !childItemId.Value.IsEmpty()) + { + resolvedChildren.Add((linkedChild, childItemId.Value)); + } + } + + var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList(); + var existingChildIds = childIdsToCheck.Count > 0 + ? context.BaseItems + .Where(e => childIdsToCheck.Contains(e.Id)) + .Select(e => e.Id) + .ToHashSet() + : []; + + var isPlaylist = folder is Playlist; + var sortOrder = 0; + foreach (var (linkedChild, childId) in resolvedChildren) + { + if (!existingChildIds.Contains(childId)) + { + _logger.LogWarning( + "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} does not exist in database", + item.Item.Name, + item.Item.Id, + childId); + continue; + } + + var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId); + if (existingLink is null) + { + context.LinkedChildren.Add(new LinkedChildEntity() + { + ParentId = item.Item.Id, + ChildId = childId, + ChildType = (DbLinkedChildType)linkedChild.Type, + SortOrder = isPlaylist ? sortOrder : null + }); + } + else + { + existingLink.SortOrder = isPlaylist ? sortOrder : null; + existingLink.ChildType = (DbLinkedChildType)linkedChild.Type; + existingLinkedChildren.Remove(existingLink); + } + + sortOrder++; + } + } + + if (existingLinkedChildren.Count > 0) + { + context.LinkedChildren.RemoveRange(existingLinkedChildren); + } + } + + // Handle Video alternate versions + if (item.Item is Video video) + { + var existingLinkedChildren = context.LinkedChildren + .Where(e => e.ParentId == video.Id && ((int)e.ChildType == 2 || (int)e.ChildType == 3)) + .ToList(); + + var newLinkedChildren = new List<(Guid ChildId, LinkedChildType Type)>(); + + // Process LocalAlternateVersions (path-based alternate versions) + if (video.LocalAlternateVersions.Length > 0) + { + var pathsToResolve = video.LocalAlternateVersions.Where(p => !string.IsNullOrEmpty(p)).ToList(); + if (pathsToResolve.Count > 0) + { + var pathToIdMap = context.BaseItems + .Where(e => e.Path != null && pathsToResolve.Contains(e.Path)) + .Select(e => new { e.Path, e.Id }) + .ToDictionary(e => e.Path!, e => e.Id); + + foreach (var path in pathsToResolve) + { + if (pathToIdMap.TryGetValue(path, out var childId)) + { + newLinkedChildren.Add((childId, LinkedChildType.LocalAlternateVersion)); + } + } + } + } + + // Process LinkedAlternateVersions (ID-based alternate versions) + if (video.LinkedAlternateVersions.Length > 0) + { + foreach (var linkedChild in video.LinkedAlternateVersions) + { + if (linkedChild.ItemId.HasValue && !linkedChild.ItemId.Value.IsEmpty()) + { + newLinkedChildren.Add((linkedChild.ItemId.Value, LinkedChildType.LinkedAlternateVersion)); + } + } + } + + // Validate that all child items exist + var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).Distinct().ToList(); + var existingChildIds = childIdsToCheck.Count > 0 + ? context.BaseItems + .Where(e => childIdsToCheck.Contains(e.Id)) + .Select(e => e.Id) + .ToHashSet() + : []; + + // Add or update LinkedChildren entries + foreach (var (childId, childType) in newLinkedChildren) + { + if (!existingChildIds.Contains(childId)) + { + _logger.LogWarning( + "Skipping alternate version for video {VideoName} ({VideoId}): child item {ChildId} does not exist in database", + video.Name, + video.Id, + childId); + continue; + } + + var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId); + if (existingLink is null) + { + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = video.Id, + ChildId = childId, + ChildType = (DbLinkedChildType)childType, + SortOrder = null + }); + } + else + { + existingLink.ChildType = (DbLinkedChildType)childType; + existingLinkedChildren.Remove(existingLink); + } + } + + // Remove orphaned alternate version links + if (existingLinkedChildren.Count > 0) + { + context.LinkedChildren.RemoveRange(existingLinkedChildren); + } + } } context.SaveChanges(); transaction.Commit(); } - /// <inheritdoc /> + /// <summary> + /// Reattaches user data entries that were incorrectly associated with a different item. + /// </summary> + /// <param name="item">The item DTO to reattach user data for.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A task representing the asynchronous operation.</returns> public async Task ReattachUserDataAsync(BaseItemDto item, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(item); @@ -795,7 +1566,8 @@ public sealed class BaseItemRepository .Include(e => e.Provider) .Include(e => e.LockedFields) .Include(e => e.UserData) - .Include(e => e.Images); + .Include(e => e.Images) + .Include(e => e.LinkedChildEntities); var item = dbQuery.FirstOrDefault(e => e.Id == id); if (item is null) @@ -958,6 +1730,17 @@ public sealed class BaseItemRepository 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) + { + folder.LinkedChildren = entity.LinkedChildEntities + .OrderBy(e => e.SortOrder) + .Select(e => new LinkedChild + { + ItemId = e.ChildId, + Type = (LinkedChildType)e.ChildType + }) + .ToArray(); + } } return dto; @@ -1143,7 +1926,7 @@ public sealed class BaseItemRepository var query = context.ItemValuesMap .AsNoTracking() - .Where(e => itemValueTypes.Any(w => (ItemValueType)w == e.ItemValue.Type)); + .Where(e => itemValueTypes.Any(w => w == e.ItemValue.Type)); if (withItemTypes.Count > 0) { query = query.Where(e => withItemTypes.Contains(e.Item.Type)); @@ -1227,7 +2010,7 @@ public sealed class BaseItemRepository { ArgumentNullException.ThrowIfNull(filter); - if (!(filter.Limit.HasValue && filter.Limit.Value > 0)) + if (!filter.Limit.HasValue) { filter.EnableTotalRecordCount = false; } @@ -1250,15 +2033,13 @@ public sealed class BaseItemRepository IsNews = filter.IsNews, IsSeries = filter.IsSeries }); - - var itemValuesQuery = context.ItemValues - .Where(f => itemValueTypes.Contains(f.Type)) - .SelectMany(f => f.BaseItemsMap!, (f, w) => new { f, w }) + var itemValuesQuery = context.ItemValuesMap + .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) .Join( innerQueryFilter, - fw => fw.w.ItemId, + ivm => ivm.ItemId, g => g.Id, - (fw, g) => fw.f.CleanValue); + (ivm, g) => ivm.ItemValue.CleanValue); var innerQuery = PrepareItemQuery(context, filter) .Where(e => e.Type == returnType) @@ -1295,6 +2076,7 @@ public sealed class BaseItemRepository .Include(e => e.Provider) .Include(e => e.LockedFields) .Include(e => e.Images) + .Include(e => e.LinkedChildEntities) .AsSingleQuery() .Where(e => masterQuery.Contains(e.Id)); @@ -1306,22 +2088,23 @@ public sealed class BaseItemRepository result.TotalRecordCount = query.Count(); } - if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0) + if (filter.Limit.HasValue || filter.StartIndex.HasValue) { - query = query.Skip(filter.StartIndex.Value); - } + var offset = filter.StartIndex ?? 0; - if (filter.Limit.HasValue && filter.Limit.Value > 0) - { - query = query.Take(filter.Limit.Value); - } + if (offset > 0) + { + query = query.Skip(offset); + } - IQueryable<BaseItemEntity>? itemCountQuery = null; + if (filter.Limit.HasValue) + { + query = query.Take(filter.Limit.Value); + } + } if (filter.IncludeItemTypes.Length > 0) { - // if we are to include more then one type, sub query those items beforehand. - var typeSubQuery = new InternalItemsQuery(filter.User) { ExcludeItemTypes = filter.ExcludeItemTypes, @@ -1335,7 +2118,7 @@ public sealed class BaseItemRepository IsPlayed = filter.IsPlayed }; - itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery) + var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery) .Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type))); var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; @@ -1346,31 +2129,49 @@ public sealed class BaseItemRepository var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; - var resultQuery = query.Select(e => new - { - item = e, - // TODO: This is bad refactor! - itemCount = new ItemCounts() - { - SeriesCount = itemCountQuery!.Count(f => f.Type == seriesTypeName), - EpisodeCount = itemCountQuery!.Count(f => f.Type == episodeTypeName), - MovieCount = itemCountQuery!.Count(f => f.Type == movieTypeName), - AlbumCount = itemCountQuery!.Count(f => f.Type == musicAlbumTypeName), - ArtistCount = itemCountQuery!.Count(f => f.Type == musicArtistTypeName), - SongCount = itemCountQuery!.Count(f => f.Type == audioTypeName), - TrailerCount = itemCountQuery!.Count(f => f.Type == trailerTypeName), - } - }); + // Get the IDs from itemCountQuery to use in the join + var itemIds = itemCountQuery.Select(e => e.Id); + + // Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite) + // Instead, start from ItemValueMaps and join with BaseItems + var countsByCleanName = context.ItemValuesMap + .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) + .Where(ivm => itemIds.Contains(ivm.ItemId)) + .Join( + context.BaseItems, + ivm => ivm.ItemId, + e => e.Id, + (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type }) + .GroupBy(x => new { x.CleanName, x.Type }) + .Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() }) + .GroupBy(x => x.CleanName) + .ToDictionary( + g => g.Key, + g => new ItemCounts + { + 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), + }); result.StartIndex = filter.StartIndex ?? 0; result.Items = [ - .. resultQuery + .. query .AsEnumerable() .Where(e => e is not null) - .Select(e => (Item: DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount)) - .Where(e => e.Item is not null) - .Select(e => (e.Item!, e.itemCount)) + .Select(e => + { + var item = DeserializeBaseItem(e, filter.SkipDeserialization); + countsByCleanName.TryGetValue(e.CleanName ?? string.Empty, out var itemCount); + return (item, itemCount); + }) + .Where(x => x.item is not null) + .Select(x => (x.item!, x.itemCount)) ]; } else @@ -1381,9 +2182,9 @@ public sealed class BaseItemRepository .. query .AsEnumerable() .Where(e => e is not null) - .Select(e => (Item: DeserializeBaseItem(e, filter.SkipDeserialization), ItemCounts: (ItemCounts?)null)) - .Where(e => e.Item is not null) - .Select(e => (e.Item!, e.ItemCounts)) + .Select(e => DeserializeBaseItem(e, filter.SkipDeserialization)) + .Where(item => item is not null) + .Select(item => (item!, (ItemCounts?)null)) ]; } @@ -1392,7 +2193,7 @@ public sealed class BaseItemRepository private static void PrepareFilterQuery(InternalItemsQuery query) { - if (query.Limit.HasValue && query.Limit.Value > 0 && query.EnableGroupByMetadataKey) + if (query.Limit.HasValue && query.EnableGroupByMetadataKey) { query.Limit = query.Limit.Value + 4; } @@ -1404,10 +2205,10 @@ public sealed class BaseItemRepository } /// <summary> - /// Gets the clean value for search and sorting purposes. + /// Normalizes a value for clean comparison by removing diacritics and converting to lowercase. /// </summary> /// <param name="value">The value to clean.</param> - /// <returns>The cleaned value.</returns> + /// <returns>The normalized value.</returns> public static string GetCleanValue(string value) { if (string.IsNullOrWhiteSpace(value)) @@ -1415,42 +2216,7 @@ public sealed class BaseItemRepository return value; } - var noDiacritics = value.RemoveDiacritics(); - - // Build a string where any punctuation or symbol is treated as a separator (space). - var sb = new StringBuilder(noDiacritics.Length); - var previousWasSpace = false; - foreach (var ch in noDiacritics) - { - char outCh; - if (char.IsLetterOrDigit(ch) || char.IsWhiteSpace(ch)) - { - outCh = ch; - } - else - { - outCh = ' '; - } - - // normalize any whitespace character to a single ASCII space. - if (char.IsWhiteSpace(outCh)) - { - if (!previousWasSpace) - { - sb.Append(' '); - previousWasSpace = true; - } - } - else - { - sb.Append(outCh); - previousWasSpace = false; - } - } - - // trim leading/trailing spaces that may have been added. - var collapsed = sb.ToString().Trim(); - return collapsed.ToLowerInvariant(); + return value.RemoveDiacritics().ToLowerInvariant(); } private List<(ItemValueType MagicNumber, string Value)> GetItemValuesToSave(BaseItemDto item, List<string> inheritedTags) @@ -1602,37 +2368,27 @@ public sealed class BaseItemRepository var orderBy = filter.OrderBy.Where(e => e.OrderBy != ItemSortBy.Default).ToArray(); var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm); - if (hasSearch) - { - orderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy]; - } - else if (orderBy.Length == 0) - { - return query.OrderBy(e => e.SortName); - } - IOrderedQueryable<BaseItemEntity>? orderedQuery = null; - // When searching, prioritize by match quality: exact match > prefix match > contains if (hasSearch) { - orderedQuery = query.OrderBy(OrderMapper.MapSearchRelevanceOrder(filter.SearchTerm!)); + var relevanceExpression = OrderMapper.MapSearchRelevanceOrder(filter.SearchTerm!); + orderedQuery = query.OrderBy(relevanceExpression); } - var firstOrdering = orderBy.FirstOrDefault(); - if (firstOrdering != default) + if (orderBy.Length > 0) { + var firstOrdering = orderBy[0]; var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context); + if (orderedQuery is null) { - // No search relevance ordering, start fresh orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending ? query.OrderBy(expression) : query.OrderByDescending(expression); } else { - // Search relevance ordering already applied, chain with ThenBy orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending ? orderedQuery.ThenBy(expression) : orderedQuery.ThenByDescending(expression); @@ -1640,26 +2396,32 @@ public sealed class BaseItemRepository if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName) { - orderedQuery = firstOrdering.SortOrder is SortOrder.Ascending + orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending ? orderedQuery.ThenBy(e => e.Name) : orderedQuery.ThenByDescending(e => e.Name); } - } - foreach (var item in orderBy.Skip(1)) - { - var expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context); - if (item.SortOrder == SortOrder.Ascending) + foreach (var item in orderBy.Skip(1)) { - orderedQuery = orderedQuery!.ThenBy(expression); - } - else - { - orderedQuery = orderedQuery!.ThenByDescending(expression); + expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context); + orderedQuery = item.SortOrder == SortOrder.Ascending + ? orderedQuery.ThenBy(expression) + : orderedQuery.ThenByDescending(expression); } } - return orderedQuery ?? query; + if (orderedQuery is null) + { + return query.OrderBy(e => e.SortName); + } + + // Add SortName as final tiebreaker + if (!hasSearch && (orderBy.Length == 0 || orderBy.All(o => o.OrderBy is not ItemSortBy.SortName and not ItemSortBy.Name))) + { + orderedQuery = orderedQuery.ThenBy(e => e.SortName); + } + + return orderedQuery; } private IQueryable<BaseItemEntity> TranslateQuery( @@ -1787,15 +2549,17 @@ public sealed class BaseItemRepository if (!string.IsNullOrEmpty(filter.SearchTerm)) { var cleanedSearchTerm = GetCleanValue(filter.SearchTerm); - var originalSearchTerm = filter.SearchTerm.ToLower(); + var originalSearchTerm = filter.SearchTerm; if (SearchWildcardTerms.Any(f => cleanedSearchTerm.Contains(f))) { cleanedSearchTerm = $"%{cleanedSearchTerm.Trim('%')}%"; - baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!, cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle.ToLower(), originalSearchTerm))); + var likeSearchTerm = $"%{originalSearchTerm.Trim('%')}%"; + baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!, cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeSearchTerm))); } else { - baseQuery = baseQuery.Where(e => e.CleanName!.Contains(cleanedSearchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(originalSearchTerm))); + var likeSearchTerm = $"%{originalSearchTerm}%"; + baseQuery = baseQuery.Where(e => e.CleanName!.Contains(cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeSearchTerm))); } } @@ -2029,28 +2793,29 @@ public sealed class BaseItemRepository } else { + var likeNameContains = $"%{nameContains}%"; baseQuery = baseQuery.Where(e => e.CleanName!.Contains(nameContains) - || e.OriginalTitle!.ToLower().Contains(nameContains!)); + || EF.Functions.Like(e.OriginalTitle, likeNameContains)); } } if (!string.IsNullOrWhiteSpace(filter.NameStartsWith)) { - var startsWithLower = filter.NameStartsWith.ToLowerInvariant(); - baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(startsWithLower)); + var nameStartsWithLower = filter.NameStartsWith.ToLowerInvariant(); + baseQuery = baseQuery.Where(e => e.SortName!.ToLower().StartsWith(nameStartsWithLower)); } if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater)) { var startsOrGreaterLower = filter.NameStartsWithOrGreater.ToLowerInvariant(); - baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(startsOrGreaterLower) >= 0); + baseQuery = baseQuery.Where(e => e.SortName!.ToLower().CompareTo(startsOrGreaterLower) >= 0); } if (!string.IsNullOrWhiteSpace(filter.NameLessThan)) { var lessThanLower = filter.NameLessThan.ToLowerInvariant(); - baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(lessThanLower ) < 0); + baseQuery = baseQuery.Where(e => e.SortName!.ToLower().CompareTo(lessThanLower) < 0); } if (filter.ImageTypes.Length > 0) @@ -2082,21 +2847,46 @@ public sealed class BaseItemRepository // We should probably figure this out for all folders, but for right now, this is the only place where we need it if (filter.IncludeItemTypes.Length == 1 && filter.IncludeItemTypes[0] == BaseItemKind.Series) { - baseQuery = baseQuery.Where(e => context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)) - .Where(e => e.IsFolder == false && e.IsVirtualItem == false) - .Where(f => f.UserData!.FirstOrDefault(e => e.UserId == filter.User!.Id && e.Played)!.Played) - .Any(f => f.SeriesPresentationUniqueKey == e.PresentationUniqueKey) == filter.IsPlayed); + // Get distinct SeriesPresentationUniqueKeys that have at least one played episode + var playedSeriesKeys = context.BaseItems + .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesPresentationUniqueKey != null) + .Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.Played)) + .Select(e => e.SeriesPresentationUniqueKey!) + .Distinct() + .ToHashSet(); + + if (filter.IsPlayed.Value) + { + if (playedSeriesKeys.Count == 0) + { + baseQuery = baseQuery.Where(e => false); + } + else + { + baseQuery = baseQuery.Where(e => playedSeriesKeys.Contains(e.PresentationUniqueKey!)); + } + } + else + { + if (playedSeriesKeys.Count == 0) + { + // No played episodes - all series are unplayed, no filter needed + } + else + { + baseQuery = baseQuery.Where(e => !playedSeriesKeys.Contains(e.PresentationUniqueKey!)); + } + } + } + else if (filter.IsPlayed.Value) + { + baseQuery = baseQuery + .Where(e => e.UserData!.Any(f => f.UserId == filter.User!.Id && f.Played)); } else { baseQuery = baseQuery - .Select(e => new - { - IsPlayed = e.UserData!.Where(f => f.UserId == filter.User!.Id).Select(f => (bool?)f.Played).FirstOrDefault() ?? false, - Item = e - }) - .Where(e => e.IsPlayed == filter.IsPlayed) - .Select(f => f.Item); + .Where(e => !e.UserData!.Any(f => f.UserId == filter.User!.Id && f.Played)); } } @@ -2184,8 +2974,10 @@ public sealed class BaseItemRepository if (filter.OfficialRatings.Length > 0) { - baseQuery = baseQuery - .Where(e => filter.OfficialRatings.Contains(e.OfficialRating)); + var ratings = filter.OfficialRatings; + Expression<Func<BaseItemEntity, bool>> hasOfficialRating = e => ratings.Contains(e.OfficialRating); + + baseQuery = baseQuery.WhereItemOrDescendantMatches(context, hasOfficialRating); } Expression<Func<BaseItemEntity, bool>>? minParentalRatingFilter = null; @@ -2268,16 +3060,12 @@ public sealed class BaseItemRepository if (filter.HasOfficialRating.HasValue) { - if (filter.HasOfficialRating.Value) - { - baseQuery = baseQuery - .Where(e => e.OfficialRating != null && e.OfficialRating != string.Empty); - } - else - { - baseQuery = baseQuery - .Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty); - } + Expression<Func<BaseItemEntity, bool>> hasRating = + e => e.OfficialRating != null && e.OfficialRating != string.Empty; + + baseQuery = filter.HasOfficialRating.Value + ? baseQuery.WhereItemOrDescendantMatches(context, hasRating) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, hasRating); } if (filter.HasOverview.HasValue) @@ -2321,38 +3109,86 @@ public sealed class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage)) { + var lang = filter.HasNoAudioTrackWithLanguage; + var foldersWithAudio = _descendantQueryProvider.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang)); + baseQuery = baseQuery - .Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio && f.Language == filter.HasNoAudioTrackWithLanguage)); + .Where(e => + (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio && ms.Language == lang)) + || (e.IsFolder && !foldersWithAudio.Contains(e.Id))); } if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage)) { + var lang = filter.HasNoInternalSubtitleTrackWithLanguage; + var foldersWithSubtitles = _descendantQueryProvider.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false)); + baseQuery = baseQuery - .Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && !f.IsExternal && f.Language == filter.HasNoInternalSubtitleTrackWithLanguage)); + .Where(e => + (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal && ms.Language == lang)) + || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); } if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage)) { + var lang = filter.HasNoExternalSubtitleTrackWithLanguage; + var foldersWithSubtitles = _descendantQueryProvider.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true)); + baseQuery = baseQuery - .Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && f.IsExternal && f.Language == filter.HasNoExternalSubtitleTrackWithLanguage)); + .Where(e => + (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal && ms.Language == lang)) + || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); } if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage)) { + var lang = filter.HasNoSubtitleTrackWithLanguage; + var foldersWithSubtitles = _descendantQueryProvider.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang)); + baseQuery = baseQuery - .Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && f.Language == filter.HasNoSubtitleTrackWithLanguage)); + .Where(e => + (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.Language == lang)) + || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); } if (filter.HasSubtitles.HasValue) { - baseQuery = baseQuery - .Where(e => e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle) == filter.HasSubtitles.Value); + var hasSubtitles = filter.HasSubtitles.Value; + var foldersWithSubtitles = _descendantQueryProvider.GetFolderIdsMatching(context, new HasSubtitles()); + if (hasSubtitles) + { + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)) + || (e.IsFolder && foldersWithSubtitles.Contains(e.Id))); + } + else + { + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)) + || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); + } } if (filter.HasChapterImages.HasValue) { - baseQuery = baseQuery - .Where(e => e.Chapters!.Any(f => f.ImagePath != null) == filter.HasChapterImages.Value); + var hasChapterImages = filter.HasChapterImages.Value; + var foldersWithChapterImages = _descendantQueryProvider.GetFolderIdsMatching(context, new HasChapterImages()); + if (hasChapterImages) + { + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && e.Chapters!.Any(f => f.ImagePath != null)) + || (e.IsFolder && foldersWithChapterImages.Contains(e.Id))); + } + else + { + baseQuery = baseQuery + .Where(e => + (!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null)) + || (e.IsFolder && !foldersWithChapterImages.Contains(e.Id))); + } } if (filter.HasDeadParentId.HasValue && filter.HasDeadParentId.Value) @@ -2467,22 +3303,22 @@ public sealed class BaseItemRepository if (filter.HasImdbId.HasValue) { baseQuery = filter.HasImdbId.Value - ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Imdb.ToString().ToLower())) - : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Imdb.ToString().ToLower())); + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == ImdbProviderName)) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != ImdbProviderName)); } if (filter.HasTmdbId.HasValue) { baseQuery = filter.HasTmdbId.Value - ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tmdb.ToString().ToLower())) - : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tmdb.ToString().ToLower())); + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == TmdbProviderName)) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != TmdbProviderName)); } if (filter.HasTvdbId.HasValue) { baseQuery = filter.HasTvdbId.Value - ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tvdb.ToString().ToLower())) - : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tvdb.ToString().ToLower())); + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == TvdbProviderName)) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != TvdbProviderName)); } var queryTopParentIds = filter.TopParentIds; @@ -2503,7 +3339,8 @@ public sealed class BaseItemRepository if (filter.AncestorIds.Length > 0) { - baseQuery = baseQuery.Where(e => e.Parents!.Any(f => filter.AncestorIds.Contains(f.ParentItemId))); + var ancestorFilter = filter.AncestorIds.OneOrManyExpressionBuilder<AncestorId, Guid>(f => f.ParentItemId); + baseQuery = baseQuery.Where(e => e.Parents!.AsQueryable().Any(ancestorFilter)); } if (!string.IsNullOrWhiteSpace(filter.AncestorWithPresentationUniqueKey)) @@ -2522,8 +3359,10 @@ public sealed class BaseItemRepository { var excludedTags = filter.ExcludeInheritedTags; baseQuery = baseQuery.Where(e => - !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue)) - && (!e.SeriesId.HasValue || !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue)))); + !context.ItemValuesMap.Any(f => + f.ItemValue.Type == ItemValueType.Tags + && excludedTags.Contains(f.ItemValue.CleanValue) + && (f.ItemId == e.Id || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value)))); } if (filter.IncludeInheritedTags.Length > 0) @@ -2531,10 +3370,10 @@ public sealed class BaseItemRepository var includeTags = filter.IncludeInheritedTags; var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist; baseQuery = baseQuery.Where(e => - e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)) - - // For seasons and episodes, we also need to check the parent series' tags. - || (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))) + context.ItemValuesMap.Any(f => + f.ItemValue.Type == ItemValueType.Tags + && includeTags.Contains(f.ItemValue.CleanValue) + && (f.ItemId == e.Id || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value))) // A playlist should be accessible to its owner regardless of allowed tags || (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""))); @@ -2556,64 +3395,131 @@ public sealed class BaseItemRepository if (filter.VideoTypes.Length > 0) { - var videoTypeBs = filter.VideoTypes.Select(e => $"\"VideoType\":\"{e}\""); - baseQuery = baseQuery - .Where(e => videoTypeBs.Any(f => e.Data!.Contains(f))); + var videoTypeBs = filter.VideoTypes.Select(vt => $"\"VideoType\":\"{vt}\"").ToArray(); + Expression<Func<BaseItemEntity, bool>> hasVideoType = e => videoTypeBs.Any(f => e.Data!.Contains(f)); + baseQuery = baseQuery.WhereItemOrDescendantMatches(context, hasVideoType); } if (filter.Is3D.HasValue) { - if (filter.Is3D.Value) - { - baseQuery = baseQuery - .Where(e => e.Data!.Contains("Video3DFormat")); - } - else - { - baseQuery = baseQuery - .Where(e => !e.Data!.Contains("Video3DFormat")); - } + Expression<Func<BaseItemEntity, bool>> is3D = e => e.Data!.Contains("Video3DFormat"); + + baseQuery = filter.Is3D.Value + ? baseQuery.WhereItemOrDescendantMatches(context, is3D) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, is3D); } if (filter.IsPlaceHolder.HasValue) { - if (filter.IsPlaceHolder.Value) - { - baseQuery = baseQuery - .Where(e => e.Data!.Contains("IsPlaceHolder\":true")); - } - else - { - baseQuery = baseQuery - .Where(e => !e.Data!.Contains("IsPlaceHolder\":true")); - } + Expression<Func<BaseItemEntity, bool>> isPlaceHolder = e => e.Data!.Contains("IsPlaceHolder\":true"); + + baseQuery = filter.IsPlaceHolder.Value + ? baseQuery.WhereItemOrDescendantMatches(context, isPlaceHolder) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, isPlaceHolder); } if (filter.HasSpecialFeature.HasValue) { - if (filter.HasSpecialFeature.Value) + var itemsWithExtras = context.BaseItems + .Where(extra => extra.OwnerId != null) + .Select(extra => extra.OwnerId!.Value) + .Distinct(); + + Expression<Func<BaseItemEntity, bool>> hasExtras = e => itemsWithExtras.Contains(e.Id); + + baseQuery = filter.HasSpecialFeature.Value + ? baseQuery.WhereItemOrDescendantMatches(context, hasExtras) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, hasExtras); + } + + if (filter.HasTrailer.HasValue) + { + var trailerOwnerIds = context.BaseItems + .Where(extra => extra.ExtraType == BaseItemExtraType.Trailer && extra.OwnerId != null) + .Select(extra => extra.OwnerId!.Value); + + Expression<Func<BaseItemEntity, bool>> hasTrailer = e => trailerOwnerIds.Contains(e.Id); + + baseQuery = filter.HasTrailer.Value + ? baseQuery.WhereItemOrDescendantMatches(context, hasTrailer) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, hasTrailer); + } + + if (filter.HasThemeSong.HasValue) + { + var themeSongOwnerIds = context.BaseItems + .Where(extra => extra.ExtraType == BaseItemExtraType.ThemeSong && extra.OwnerId != null) + .Select(extra => extra.OwnerId!.Value); + + Expression<Func<BaseItemEntity, bool>> hasThemeSong = e => themeSongOwnerIds.Contains(e.Id); + + baseQuery = filter.HasThemeSong.Value + ? baseQuery.WhereItemOrDescendantMatches(context, hasThemeSong) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, hasThemeSong); + } + + if (filter.HasThemeVideo.HasValue) + { + var themeVideoOwnerIds = context.BaseItems + .Where(extra => extra.ExtraType == BaseItemExtraType.ThemeVideo && extra.OwnerId != null) + .Select(extra => extra.OwnerId!.Value); + + Expression<Func<BaseItemEntity, bool>> hasThemeVideo = e => themeVideoOwnerIds.Contains(e.Id); + + baseQuery = filter.HasThemeVideo.Value + ? baseQuery.WhereItemOrDescendantMatches(context, hasThemeVideo) + : baseQuery.WhereNeitherItemNorDescendantMatches(context, hasThemeVideo); + } + + if (filter.AiredDuringSeason.HasValue) + { + var seasonNumber = filter.AiredDuringSeason.Value; + if (seasonNumber < 1) { - baseQuery = baseQuery - .Where(e => e.Extras != null && e.Extras.Count > 0); + baseQuery = baseQuery.Where(e => e.ParentIndexNumber == seasonNumber); } else { - baseQuery = baseQuery - .Where(e => e.Extras == null || e.Extras.Count == 0); + var seasonStr = seasonNumber.ToString(CultureInfo.InvariantCulture); + baseQuery = baseQuery.Where(e => + e.ParentIndexNumber == seasonNumber + || (e.Data != null && ( + e.Data.Contains("\"AirsAfterSeasonNumber\":" + seasonStr) + || e.Data.Contains("\"AirsBeforeSeasonNumber\":" + seasonStr)))); } } - if (filter.HasTrailer.HasValue || filter.HasThemeSong.HasValue || filter.HasThemeVideo.HasValue) + if (filter.AdjacentTo.HasValue && !filter.AdjacentTo.Value.IsEmpty()) { - if (filter.HasTrailer.GetValueOrDefault() || filter.HasThemeSong.GetValueOrDefault() || filter.HasThemeVideo.GetValueOrDefault()) + 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) { - baseQuery = baseQuery - .Where(e => e.Extras != null && e.Extras.Count > 0); - } - else - { - baseQuery = baseQuery - .Where(e => e.Extras == null || e.Extras.Count == 0); + var targetSortName = targetItem.SortName ?? string.Empty; + var prevId = context.BaseItems + .Where(e => string.Compare(e.SortName, targetSortName) < 0) + .OrderByDescending(e => e.SortName) + .Select(e => e.Id) + .FirstOrDefault(); + + var nextId = context.BaseItems + .Where(e => string.Compare(e.SortName, targetSortName) > 0) + .OrderBy(e => e.SortName) + .Select(e => e.Id) + .FirstOrDefault(); + + var adjacentIds = new List<Guid> { adjacentToId }; + if (prevId != Guid.Empty) + { + adjacentIds.Add(prevId); + } + + if (nextId != Guid.Empty) + { + adjacentIds.Add(nextId); + } + + baseQuery = baseQuery.Where(e => adjacentIds.Contains(e.Id)); } } @@ -2637,49 +3543,215 @@ public sealed class BaseItemRepository if (recursive) { - var folderList = TraverseHirachyDown(id, dbContext, item => (item.IsFolder || item.IsVirtualItem)); + var descendantIds = _descendantQueryProvider.GetAllDescendantIds(dbContext, id); return dbContext.BaseItems - .Where(e => folderList.Contains(e.ParentId!.Value) && !e.IsFolder && !e.IsVirtualItem) + .Where(e => descendantIds.Contains(e.Id) && !e.IsFolder && !e.IsVirtualItem) .All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played)); } return dbContext.BaseItems.Where(e => e.ParentId == id).All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played)); } - private static HashSet<Guid> TraverseHirachyDown(Guid parentId, JellyfinDbContext dbContext, Expression<Func<BaseItemEntity, bool>>? filter = null) + /// <inheritdoc/> + public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId) { - var folderStack = new HashSet<Guid>() - { - parentId - }; - var folderList = new HashSet<Guid>() - { - parentId - }; + ArgumentNullException.ThrowIfNull(filter.User); + using var dbContext = _dbProvider.CreateDbContext(); + + var baseQuery = BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId); + return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played)); + } + + /// <inheritdoc/> + public int GetTotalCount(InternalItemsQuery filter, Guid ancestorId) + { + using var dbContext = _dbProvider.CreateDbContext(); + + var baseQuery = BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId); + return baseQuery.Count(); + } + + /// <inheritdoc/> + public (int Played, int Total) GetPlayedAndTotalCount(InternalItemsQuery filter, Guid ancestorId) + { + ArgumentNullException.ThrowIfNull(filter); + ArgumentNullException.ThrowIfNull(filter.User); + using var dbContext = _dbProvider.CreateDbContext(); - while (folderStack.Count != 0) + var baseQuery = BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId); + return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id); + } + + /// <inheritdoc/> + public (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId) + { + ArgumentNullException.ThrowIfNull(filter); + ArgumentNullException.ThrowIfNull(filter.User); + using var dbContext = _dbProvider.CreateDbContext(); + + var allDescendantIds = _descendantQueryProvider.GetAllDescendantIds(dbContext, parentId); + var baseQuery = dbContext.BaseItems + .Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem); + baseQuery = ApplyAccessFiltering(dbContext, baseQuery, filter); + + return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id); + } + + /// <inheritdoc/> + public IReadOnlyList<Guid> GetLinkedChildrenIds(Guid parentId, int? childType = null) + { + using var dbContext = _dbProvider.CreateDbContext(); + + var query = dbContext.LinkedChildren + .Where(lc => lc.ParentId.Equals(parentId)); + + if (childType.HasValue) { - var items = folderStack.ToArray(); - folderStack.Clear(); - var query = dbContext.BaseItems - .WhereOneOrMany(items, e => e.ParentId!.Value); + query = query.Where(lc => (int)lc.ChildType == childType.Value); + } - if (filter != null) - { - query = query.Where(filter); - } + return query + .Select(lc => lc.ChildId) + .ToList(); + } - foreach (var item in query.Select(e => e.Id).ToArray()) + private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId) + { + var result = query + .Select(b => b.UserData!.Any(u => u.UserId == userId && u.Played)) + .GroupBy(_ => 1) + .Select(g => new { - if (folderList.Add(item)) - { - folderStack.Add(item); - } - } + Total = g.Count(), + Played = g.Count(isPlayed => isPlayed) + }) + .FirstOrDefault(); + + return result is null ? (0, 0) : (result.Played, result.Total); + } + + /// <inheritdoc/> + public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId) + { + ArgumentNullException.ThrowIfNull(parentIds); + + if (parentIds.Count == 0) + { + return new Dictionary<Guid, int>(); + } + + using var dbContext = _dbProvider.CreateDbContext(); + + // Convert to array for EF Core Contains support + var parentIdsArray = parentIds.ToArray(); + + // Count hierarchical children (immediate children via ParentId) + var hierarchicalCounts = dbContext.BaseItems + .Where(b => b.ParentId.HasValue && parentIdsArray.Contains(b.ParentId.Value)) + .GroupBy(b => b.ParentId!.Value) + .Select(g => new { ParentId = g.Key, Count = g.Count() }) + .ToDictionary(x => x.ParentId, x => x.Count); + + // Count linked children (BoxSets, Playlists, etc.) + var linkedCounts = dbContext.LinkedChildren + .Where(lc => parentIdsArray.Contains(lc.ParentId)) + .GroupBy(lc => lc.ParentId) + .Select(g => new { ParentId = g.Key, Count = g.Count() }) + .ToDictionary(x => x.ParentId, x => x.Count); + + // Merge results + var result = new Dictionary<Guid, int>(); + foreach (var parentId in parentIds) + { + var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0); + var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0); + + // If there are linked children, use that count (matches Folder.GetChildCount logic) + // Otherwise use hierarchical count + result[parentId] = linkedCount > 0 ? linkedCount : hierarchicalCount; + } + + return result; + } + + /// <summary> + /// Builds a query for descendants of an ancestor with user access filtering applied. + /// Uses recursive CTE to traverse both hierarchical (AncestorIds) and linked (LinkedChildren) relationships. + /// </summary> + private IQueryable<BaseItemEntity> BuildAccessFilteredDescendantsQuery( + JellyfinDbContext context, + InternalItemsQuery filter, + Guid ancestorId) + { + // Use recursive CTE to get all descendants (hierarchical and linked) + var allDescendantIds = _descendantQueryProvider.GetAllDescendantIds(context, ancestorId); + + var baseQuery = context.BaseItems + .Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem); + + return ApplyAccessFiltering(context, baseQuery, filter); + } + + /// <summary> + /// Applies user access filtering to a query. + /// Includes TopParentIds, parental rating, and tag filtering. + /// </summary> + private IQueryable<BaseItemEntity> ApplyAccessFiltering( + JellyfinDbContext context, + IQueryable<BaseItemEntity> baseQuery, + InternalItemsQuery filter) + { + // Apply TopParentIds filtering (library folder access) + if (filter.TopParentIds.Length > 0) + { + var topParentIds = filter.TopParentIds; + baseQuery = baseQuery.Where(e => topParentIds.Contains(e.TopParentId!.Value)); } - return folderList; + // Apply parental rating filtering + if (filter.MaxParentalRating is not null) + { + var maxScore = filter.MaxParentalRating.Score; + var maxSubScore = filter.MaxParentalRating.SubScore ?? 0; + + baseQuery = baseQuery.Where(e => + e.InheritedParentalRatingValue == null || + e.InheritedParentalRatingValue < maxScore || + (e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore)); + } + + // Apply block unrated items filtering + if (filter.BlockUnratedItems.Length > 0) + { + var unratedItemTypes = filter.BlockUnratedItems.Select(f => f.ToString()).ToArray(); + baseQuery = baseQuery.Where(e => + e.InheritedParentalRatingValue != null || !unratedItemTypes.Contains(e.UnratedType)); + } + + // Apply excluded tags filtering (blocked tags) + if (filter.ExcludeInheritedTags.Length > 0) + { + var excludedTags = filter.ExcludeInheritedTags; + baseQuery = baseQuery.Where(e => + !context.ItemValuesMap.Any(f => + f.ItemValue.Type == ItemValueType.Tags + && excludedTags.Contains(f.ItemValue.CleanValue) + && (f.ItemId == e.Id || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value)))); + } + + // Apply included tags filtering (allowed tags - item must have at least one) + if (filter.IncludeInheritedTags.Length > 0) + { + var includeTags = filter.IncludeInheritedTags; + baseQuery = baseQuery.Where(e => + context.ItemValuesMap.Any(f => + f.ItemValue.Type == ItemValueType.Tags + && includeTags.Contains(f.ItemValue.CleanValue) + && (f.ItemId == e.Id || (e.SeriesId.HasValue && f.ItemId == e.SeriesId.Value)))); + } + + return baseQuery; } /// <inheritdoc/> |
