diff options
Diffstat (limited to 'Jellyfin.Server.Implementations')
5 files changed, 238 insertions, 55 deletions
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs index 5a41619390..70e4ca3b1d 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -121,20 +121,22 @@ public sealed partial class BaseItemRepository { using var context = _dbProvider.CreateDbContext(); - var query = context.ItemValuesMap - .AsNoTracking() - .Where(e => itemValueTypes.Any(w => w == e.ItemValue.Type)); + var maps = context.ItemValuesMap.AsNoTracking(); if (withItemTypes.Count > 0) { - query = query.Where(e => withItemTypes.Contains(e.Item.Type)); + maps = maps.Where(e => withItemTypes.Contains(e.Item.Type)); } if (excludeItemTypes.Count > 0) { - query = query.Where(e => !excludeItemTypes.Contains(e.Item.Type)); + maps = maps.Where(e => !excludeItemTypes.Contains(e.Item.Type)); } - return query.Select(e => e.ItemValue) + return context.ItemValues + .AsNoTracking() + .WhereOneOrMany(itemValueTypes, e => e.Type) + .Where(e => maps.Any(m => m.ItemValueId == e.ItemValueId)) + .Select(e => new { e.CleanValue, e.Value }) .GroupBy(e => e.CleanValue) .Select(g => g.Min(v => v.Value)!) .ToArray(); @@ -246,14 +248,20 @@ public sealed partial class BaseItemRepository } result.StartIndex = filter.StartIndex ?? 0; - if (filter.IncludeItemTypes.Length > 0) + var page = query.AsEnumerable().Where(e => e is not null).ToList(); + + if (filter.DtoOptions.ContainsField(ItemFields.ItemCounts)) { - var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes); + var pageCleanNames = page + .Where(e => !string.IsNullOrEmpty(e.CleanName)) + .Select(e => e.CleanName!) + .Distinct() + .ToList(); + + var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes, pageCleanNames); result.Items = [ - .. query - .AsEnumerable() - .Where(e => e is not null) + .. page .Select(e => { var item = DeserializeBaseItem(e, filter.SkipDeserialization); @@ -268,9 +276,7 @@ public sealed partial class BaseItemRepository { result.Items = [ - .. query - .AsEnumerable() - .Where(e => e != null) + .. page .Select(e => DeserializeBaseItem(e, filter.SkipDeserialization)) .Where(item => item != null) .Select(item => (item!, (ItemCounts?)null)) @@ -281,14 +287,22 @@ public sealed partial class BaseItemRepository } private Dictionary<string, ItemCounts> BuildItemCountsByCleanName( - Database.Implementations.JellyfinDbContext context, + JellyfinDbContext context, InternalItemsQuery filter, - IReadOnlyList<ItemValueType> itemValueTypes) + IReadOnlyList<ItemValueType> itemValueTypes, + IReadOnlyList<string> cleanNames) { - var typeSubQuery = new InternalItemsQuery(filter.User) + var countsByCleanName = new Dictionary<string, ItemCounts>(); + if (cleanNames.Count == 0) + { + return countsByCleanName; + } + + // The counts describe everything the value is attached to, not only the types the list was + // filtered down to. + var scopeQuery = new InternalItemsQuery(filter.User) { ExcludeItemTypes = filter.ExcludeItemTypes, - IncludeItemTypes = filter.IncludeItemTypes, MediaTypes = filter.MediaTypes, AncestorIds = filter.AncestorIds, ExcludeItemIds = filter.ExcludeItemIds, @@ -298,33 +312,51 @@ public sealed partial class BaseItemRepository IsPlayed = filter.IsPlayed }; - var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery) - .Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type))); + var scopedItems = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, scopeQuery); + var valueLinks = context.ItemValuesMap + .AsNoTracking() + .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) + .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue); var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]; var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; + var musicVideoTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicVideo]; + var programTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.LiveTvProgram]; var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; - var itemIds = itemCountQuery.Select(e => e.Id); // Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite) // Instead, start from ItemValueMaps and join with BaseItems. - var rawCounts = context.ItemValuesMap - .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) - .Where(ivm => itemIds.Contains(ivm.ItemId)) + var rawCounts = valueLinks .Join( - context.BaseItems, + scopedItems, ivm => ivm.ItemId, e => e.Id, - (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type }) - .GroupBy(x => new { x.CleanName, x.Type }) - .Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() }) - .AsEnumerable(); + (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type, e.SeriesId }) + .GroupBy(x => new { x.CleanName, x.Type, x.SeriesId }) + .Select(g => new { g.Key.CleanName, g.Key.Type, g.Key.SeriesId, Count = g.Count() }) + .ToList(); + + // Only studios and genres pass down from a series to its episodes; an artist credit does not. + var inheritsToEpisodes = itemValueTypes.Contains(ItemValueType.Studios) || itemValueTypes.Contains(ItemValueType.Genre); + var episodeCounts = inheritsToEpisodes + ? BuildEpisodeCountsByCleanName( + scopedItems, + valueLinks, + rawCounts + .Where(x => x.Type == episodeTypeName) + .Select(x => (x.CleanName, x.SeriesId, x.Count)) + .ToList(), + seriesTypeName, + episodeTypeName) + : rawCounts + .Where(x => x.Type == episodeTypeName) + .GroupBy(x => x.CleanName) + .ToDictionary(g => g.Key, g => g.Sum(x => x.Count)); - var countsByCleanName = new Dictionary<string, ItemCounts>(); foreach (var group in rawCounts.GroupBy(x => x.CleanName)) { var counts = new ItemCounts(); @@ -334,10 +366,6 @@ public sealed partial class BaseItemRepository { counts.SeriesCount += row.Count; } - else if (row.Type == episodeTypeName) - { - counts.EpisodeCount += row.Count; - } else if (row.Type == movieTypeName) { counts.MovieCount += row.Count; @@ -350,6 +378,14 @@ public sealed partial class BaseItemRepository { counts.ArtistCount += row.Count; } + else if (row.Type == musicVideoTypeName) + { + counts.MusicVideoCount += row.Count; + } + else if (row.Type == programTypeName) + { + counts.ProgramCount += row.Count; + } else if (row.Type == audioTypeName) { counts.SongCount += row.Count; @@ -360,9 +396,72 @@ public sealed partial class BaseItemRepository } } + // Episodes are counted separately: the value is usually only written on the series. + counts.EpisodeCount = episodeCounts.GetValueOrDefault(group.Key); + counts.ItemCount = counts.TotalItemCount(); countsByCleanName[group.Key] = counts; } + // A value carried by nothing but the episodes below a tagged series has no row of its own. + foreach (var (cleanName, episodeCount) in episodeCounts) + { + if (!countsByCleanName.ContainsKey(cleanName)) + { + countsByCleanName[cleanName] = new ItemCounts { EpisodeCount = episodeCount, ItemCount = episodeCount }; + } + } + return countsByCleanName; } + + private static Dictionary<string, int> BuildEpisodeCountsByCleanName( + IQueryable<BaseItemEntity> scopedItems, + IQueryable<ItemValueMap> valueLinks, + IReadOnlyList<(string CleanName, Guid? SeriesId, int Count)> taggedEpisodes, + string seriesTypeName, + string episodeTypeName) + { + // Resolved in steps rather than as one union: each of these drives off an index, while the + // single-statement form leaves SQLite free to scan every episode in the library instead. + var taggedSeries = valueLinks + .Join( + scopedItems.Where(e => e.Type == seriesTypeName), + ivm => ivm.ItemId, + e => e.Id, + (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, SeriesId = e.Id }) + .ToList(); + + var seriesIds = taggedSeries.Select(x => x.SeriesId).Distinct().ToArray(); + var episodesPerSeries = seriesIds.Length == 0 + ? [] + : scopedItems + .Where(e => e.Type == episodeTypeName && e.SeriesId != null) + .WhereOneOrMany(seriesIds, e => e.SeriesId!.Value) + .GroupBy(e => e.SeriesId!.Value) + .Select(g => new { SeriesId = g.Key, Count = g.Count() }) + .ToDictionary(x => x.SeriesId, x => x.Count); + + var episodeCounts = new Dictionary<string, int>(); + var seriesByCleanName = new Dictionary<string, HashSet<Guid>>(); + foreach (var group in taggedSeries.GroupBy(x => x.CleanName)) + { + var series = group.Select(x => x.SeriesId).ToHashSet(); + seriesByCleanName[group.Key] = series; + episodeCounts[group.Key] = series.Sum(id => episodesPerSeries.GetValueOrDefault(id)); + } + + foreach (var (cleanName, seriesId, count) in taggedEpisodes) + { + if (seriesId is not null + && seriesByCleanName.TryGetValue(cleanName, out var series) + && series.Contains(seriesId.Value)) + { + continue; + } + + episodeCounts[cleanName] = episodeCounts.GetValueOrDefault(cleanName) + count; + } + + return episodeCounts; + } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs index c9e08b1b5d..1ed10cce2b 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs @@ -626,18 +626,26 @@ public sealed partial class BaseItemRepository .ToArray(); var tags = context.ItemValuesMap - .Where(ivm => ivm.ItemValue.Type == ItemValueType.Tags) - .Where(ivm => matchingItemIds.Contains(ivm.ItemId)) - .Select(ivm => ivm.ItemValue) + .Join( + context.ItemValues, + ivm => ivm.ItemValueId, + iv => iv.ItemValueId, + (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value }) + .Where(iv => iv.Type == ItemValueType.Tags) + .Where(iv => matchingItemIds.Contains(iv.ItemId)) .GroupBy(iv => iv.CleanValue) .Select(g => g.Min(iv => iv.Value)) .OrderBy(t => t) .ToArray(); var genres = context.ItemValuesMap - .Where(ivm => ivm.ItemValue.Type == ItemValueType.Genre) - .Where(ivm => matchingItemIds.Contains(ivm.ItemId)) - .Select(ivm => ivm.ItemValue) + .Join( + context.ItemValues, + ivm => ivm.ItemValueId, + iv => iv.ItemValueId, + (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value }) + .Where(iv => iv.Type == ItemValueType.Genre) + .Where(iv => matchingItemIds.Contains(iv.ItemId)) .GroupBy(iv => iv.CleanValue) .Select(g => g.Min(iv => iv.Value)) .OrderBy(g => g) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 1e30f0164e..d635b38df5 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -1091,6 +1091,12 @@ public sealed partial class BaseItemRepository baseQuery = baseQuery.Where(e => e.Parents!.AsQueryable().Any(ancestorFilter)); } + if (filter.DescendantOfId.HasValue) + { + var descendantIds = DescendantQueryHelper.GetAllDescendantIds(context, filter.DescendantOfId.Value); + baseQuery = baseQuery.Where(e => descendantIds.Contains(e.Id)); + } + if (filter.LinkedChildAncestorIds.Length > 0) { // Keep folder-like items (BoxSets, Playlists) whose linked children descend from any of the requested ancestor ids. diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs index c42b5f9581..704dc31fd0 100644 --- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -249,11 +249,51 @@ public class ItemCountService : IItemCountService } } + if (kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre + && relatedItemKinds.Contains(BaseItemKind.Episode) + && relatedItemKinds.Contains(BaseItemKind.Series)) + { + var rolledUpEpisodeCount = CountEpisodesOfTaggedSeries(context, baseQuery, accessFilter, out var directEpisodeCount); + totalCount += rolledUpEpisodeCount - result.EpisodeCount + directEpisodeCount; + result.EpisodeCount = rolledUpEpisodeCount + directEpisodeCount; + } + result.ItemCount = totalCount; return result; } + private int CountEpisodesOfTaggedSeries( + JellyfinDbContext context, + IQueryable<BaseItemEntity> taggedItems, + InternalItemsQuery accessFilter, + out int unrelatedEpisodeCount) + { + var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; + var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; + + var taggedSeriesIds = taggedItems.Where(e => e.Type == seriesTypeName).Select(e => e.Id); + unrelatedEpisodeCount = taggedItems.Count(e => e.Type == episodeTypeName + && (e.SeriesId == null || !taggedSeriesIds.Contains(e.SeriesId.Value))); + + // Materialised so the episode count drives off IX_BaseItems_SeriesId. + var seriesIds = taggedItems + .Where(e => e.Type == seriesTypeName) + .Select(e => e.Id) + .ToArray(); + + if (seriesIds.Length == 0) + { + return 0; + } + + var episodes = context.BaseItems.AsNoTracking() + .Where(e => e.Type == episodeTypeName && e.SeriesId != null) + .WhereOneOrMany(seriesIds, e => e.SeriesId!.Value); + + return _queryHelpers.ApplyAccessFiltering(context, episodes, accessFilter).Count(); + } + private static IQueryable<BaseItemEntity> ItemsById(JellyfinDbContext context, IQueryable<Guid> itemIds) => context.BaseItems.AsNoTracking().Where(e => itemIds.Contains(e.Id)); @@ -319,7 +359,7 @@ public class ItemCountService : IItemCountService } /// <inheritdoc/> - public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId) + public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user) { ArgumentNullException.ThrowIfNull(parentIds); @@ -332,20 +372,32 @@ public class ItemCountService : IItemCountService var parentIdsArray = parentIds.ToArray(); + var includeVirtual = user is null || user.DisplayMissingEpisodes; + var hierarchicalCounts = dbContext.BaseItems - .Where(b => b.ParentId.HasValue) + .Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) .WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value) .GroupBy(b => b.ParentId!.Value) .Select(g => new { ParentId = g.Key, Count = g.Count() }) .ToDictionary(x => x.ParentId, x => x.Count); + // An episode is a child of its season even when it is not stored under one: with a flat + // structure ParentId points at the series, so counting by ParentId alone leaves the season + // empty and counts its episodes towards the series instead. + var seasonCounts = dbContext.BaseItems + .Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) + .WhereOneOrMany(parentIdsArray, b => b.SeasonId!.Value) + .GroupBy(b => b.SeasonId!.Value) + .Select(g => new { SeasonId = g.Key, Count = g.Count() }) + .ToDictionary(x => x.SeasonId, x => x.Count); + var linkedCounts = dbContext.LinkedChildren .WhereOneOrMany(parentIdsArray, lc => lc.ParentId) .GroupBy(lc => lc.ParentId) .Select(g => new { ParentId = g.Key, Count = g.Count() }) .ToDictionary(x => x.ParentId, x => x.Count); - var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray); + var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray, includeVirtual); var result = new Dictionary<Guid, int>(); foreach (var parentId in parentIds) @@ -356,7 +408,8 @@ public class ItemCountService : IItemCountService continue; } - var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0); + var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0) + + seasonCounts.GetValueOrDefault(parentId, 0); var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0); result[parentId] = linkedCount > 0 ? linkedCount : hierarchicalCount; @@ -365,7 +418,7 @@ public class ItemCountService : IItemCountService return result; } - private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds) + private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds, bool includeVirtual) { var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds) .Where(group => group.Value.Count > 1) @@ -380,10 +433,16 @@ public class ItemCountService : IItemCountService var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray(); var children = dbContext.BaseItems .AsNoTracking() - .Where(b => b.ParentId.HasValue) + .Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) .WhereOneOrMany(memberIds, b => b.ParentId!.Value) .Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey }) .ToArray() + .Concat(dbContext.BaseItems + .AsNoTracking() + .Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem)) + .WhereOneOrMany(memberIds, b => b.SeasonId!.Value) + .Select(b => new { ParentId = b.SeasonId!.Value, b.Id, b.PresentationUniqueKey }) + .ToArray()) .GroupBy(b => b.ParentId) .ToDictionary( g => g.Key, diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs index efff3457a3..c8672e189b 100644 --- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs @@ -176,14 +176,6 @@ public class ItemPersistenceService : IItemPersistenceService var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { - if (!await context.BaseItems - .AnyAsync(bi => bi.Id == item.Id, cancellationToken) - .ConfigureAwait(false)) - { - _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem"); - return; - } - await context.BaseItemImageInfos .Where(e => e.ItemId == item.Id) .ExecuteDeleteAsync(cancellationToken) @@ -193,7 +185,26 @@ public class ItemPersistenceService : IItemPersistenceService .AddRangeAsync(images, cancellationToken) .ConfigureAwait(false); - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + try + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + catch (DbUpdateException) + { + // Checking that the item exists before writing leaves a gap a scan can delete it + // through, turning the insert into a foreign key violation that fails the whole + // refresh instead of the no-op intended here. Let the insert be the check: it is the + // only point at which the answer cannot go stale. Nothing is orphaned by the delete + // above, because deleting the item cascades to its images anyway. + if (await context.BaseItems + .AnyAsync(bi => bi.Id == item.Id, cancellationToken) + .ConfigureAwait(false)) + { + throw; + } + + _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem {ItemId}", item.Id); + } } } |
