aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server.Implementations
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Server.Implementations')
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs167
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs20
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs6
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemCountService.cs71
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs29
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);
+ }
}
}