< Summary - Jellyfin

Information
Class: Jellyfin.Server.Implementations.Item.ItemCountService
Assembly: Jellyfin.Server.Implementations
File(s): /srv/git/jellyfin/Jellyfin.Server.Implementations/Item/ItemCountService.cs
Line coverage
87%
Covered lines: 361
Uncovered lines: 51
Coverable lines: 412
Total lines: 758
Line coverage: 87.6%
Branch coverage
70%
Covered branches: 100
Total branches: 142
Branch coverage: 70.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/16/2026 - 12:15:55 AM Line coverage: 1.7% (4/235) Branch coverage: 0% (0/75) Total lines: 4278/2/2026 - 12:17:28 AM Line coverage: 1.6% (4/236) Branch coverage: 0% (0/75) Total lines: 4308/3/2026 - 12:16:46 AM Line coverage: 1.6% (4/237) Branch coverage: 0% (0/75) Total lines: 4318/18/2026 - 12:17:16 AM Line coverage: 10.9% (26/238) Branch coverage: 6.6% (5/75) Total lines: 43210/29/2026 - 12:17:11 AM Line coverage: 87.6% (361/412) Branch coverage: 70.4% (100/142) Total lines: 758 5/16/2026 - 12:15:55 AM Line coverage: 1.7% (4/235) Branch coverage: 0% (0/75) Total lines: 4278/2/2026 - 12:17:28 AM Line coverage: 1.6% (4/236) Branch coverage: 0% (0/75) Total lines: 4308/3/2026 - 12:16:46 AM Line coverage: 1.6% (4/237) Branch coverage: 0% (0/75) Total lines: 4318/18/2026 - 12:17:16 AM Line coverage: 10.9% (26/238) Branch coverage: 6.6% (5/75) Total lines: 43210/29/2026 - 12:17:11 AM Line coverage: 87.6% (361/412) Branch coverage: 70.4% (100/142) Total lines: 758

Coverage delta

Coverage delta 77 -77

Metrics

File(s)

/srv/git/jellyfin/Jellyfin.Server.Implementations/Item/ItemCountService.cs

#LineLine coverage
 1#pragma warning disable RS0030 // Do not use banned APIs
 2
 3using System;
 4using System.Collections.Generic;
 5using System.Globalization;
 6using System.Linq;
 7using Jellyfin.Data.Enums;
 8using Jellyfin.Database.Implementations;
 9using Jellyfin.Database.Implementations.Entities;
 10using Jellyfin.Database.Implementations.Enums;
 11using Jellyfin.Extensions;
 12using MediaBrowser.Controller.Entities;
 13using MediaBrowser.Controller.Persistence;
 14using MediaBrowser.Model.Dto;
 15using Microsoft.EntityFrameworkCore;
 16
 17namespace Jellyfin.Server.Implementations.Item;
 18
 19/// <summary>
 20/// Provides item counting and played-status query operations.
 21/// </summary>
 22public class ItemCountService : IItemCountService
 23{
 24    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 25    private readonly IItemTypeLookup _itemTypeLookup;
 26    private readonly IItemQueryHelpers _queryHelpers;
 27
 28    /// <summary>
 29    /// Initializes a new instance of the <see cref="ItemCountService"/> class.
 30    /// </summary>
 31    /// <param name="dbProvider">The database context factory.</param>
 32    /// <param name="itemTypeLookup">The item type lookup.</param>
 33    /// <param name="queryHelpers">The shared query helpers.</param>
 34    public ItemCountService(
 35        IDbContextFactory<JellyfinDbContext> dbProvider,
 36        IItemTypeLookup itemTypeLookup,
 37        IItemQueryHelpers queryHelpers)
 38    {
 5039        _dbProvider = dbProvider;
 5040        _itemTypeLookup = itemTypeLookup;
 5041        _queryHelpers = queryHelpers;
 5042    }
 43
 44    /// <inheritdoc/>
 45    public int GetCount(InternalItemsQuery filter)
 46    {
 047        ArgumentNullException.ThrowIfNull(filter);
 048        _queryHelpers.PrepareFilterQuery(filter);
 49
 050        using var context = _dbProvider.CreateDbContext();
 051        var dbQuery = _queryHelpers.TranslateQuery(context.BaseItems.AsNoTracking(), context, filter);
 52
 053        return dbQuery.Count();
 054    }
 55
 56    /// <inheritdoc />
 57    public ItemCounts GetItemCounts(InternalItemsQuery filter)
 58    {
 059        ArgumentNullException.ThrowIfNull(filter);
 060        _queryHelpers.PrepareFilterQuery(filter);
 61
 062        using var context = _dbProvider.CreateDbContext();
 063        var dbQuery = _queryHelpers.TranslateQuery(context.BaseItems.AsNoTracking(), context, filter);
 64
 065        var counts = dbQuery
 066            .GroupBy(x => x.Type)
 067            .Select(x => new { x.Key, Count = x.Count() })
 068            .ToArray();
 69
 070        var lookup = _itemTypeLookup.BaseItemKindNames;
 071        var result = new ItemCounts
 072        {
 073            ItemCount = counts.Sum(c => c.Count)
 074        };
 075        foreach (var count in counts)
 76        {
 077            if (string.Equals(count.Key, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal))
 78            {
 079                result.AlbumCount = count.Count;
 80            }
 081            else if (string.Equals(count.Key, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal))
 82            {
 083                result.ArtistCount = count.Count;
 84            }
 085            else if (string.Equals(count.Key, lookup[BaseItemKind.Episode], StringComparison.Ordinal))
 86            {
 087                result.EpisodeCount = count.Count;
 88            }
 089            else if (string.Equals(count.Key, lookup[BaseItemKind.Movie], StringComparison.Ordinal))
 90            {
 091                result.MovieCount = count.Count;
 92            }
 093            else if (string.Equals(count.Key, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal))
 94            {
 095                result.MusicVideoCount = count.Count;
 96            }
 097            else if (string.Equals(count.Key, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal))
 98            {
 099                result.ProgramCount = count.Count;
 100            }
 0101            else if (string.Equals(count.Key, lookup[BaseItemKind.Series], StringComparison.Ordinal))
 102            {
 0103                result.SeriesCount = count.Count;
 104            }
 0105            else if (string.Equals(count.Key, lookup[BaseItemKind.Audio], StringComparison.Ordinal))
 106            {
 0107                result.SongCount = count.Count;
 108            }
 0109            else if (string.Equals(count.Key, lookup[BaseItemKind.Trailer], StringComparison.Ordinal))
 110            {
 0111                result.TrailerCount = count.Count;
 112            }
 0113            else if (string.Equals(count.Key, lookup[BaseItemKind.BoxSet], StringComparison.Ordinal))
 114            {
 0115                result.BoxSetCount = count.Count;
 116            }
 0117            else if (string.Equals(count.Key, lookup[BaseItemKind.Book], StringComparison.Ordinal))
 118            {
 0119                result.BookCount = count.Count;
 120            }
 121        }
 122
 0123        return result;
 0124    }
 125
 126    /// <inheritdoc />
 127    public ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, InternalItem
 128    {
 20129        return GetItemCountsForNameItems(kind, [id], relatedItemKinds, accessFilter)[id];
 130    }
 131
 132    private static ItemValueType[] GetItemValueTypes(BaseItemKind kind)
 33133        => kind switch
 33134        {
 5135            BaseItemKind.MusicArtist => [ItemValueType.Artist, ItemValueType.AlbumArtist],
 20136            BaseItemKind.Genre or BaseItemKind.MusicGenre => [ItemValueType.Genre],
 0137            BaseItemKind.Studio => [ItemValueType.Studios],
 8138            _ => []
 33139        };
 140
 141    /// <inheritdoc />
 142    public Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKi
 143    {
 34144        ArgumentNullException.ThrowIfNull(ids);
 34145        ArgumentNullException.ThrowIfNull(relatedItemKinds);
 34146        ArgumentNullException.ThrowIfNull(accessFilter);
 147
 34148        var result = new Dictionary<Guid, ItemCounts>();
 34149        if (ids.Count == 0)
 150        {
 0151            return result;
 152        }
 153
 34154        using var context = _dbProvider.CreateDbContext();
 155
 34156        var idsArray = ids as Guid[] ?? ids.ToArray();
 34157        var nameItems = context.BaseItems.AsNoTracking()
 34158            .WhereOneOrMany(idsArray, e => e.Id)
 34159            .Select(e => new NameItem(e.Id, e.Name, e.CleanName))
 34160            .ToArray();
 161
 80272162        foreach (var id in ids)
 163        {
 40102164            result[id] = new ItemCounts();
 165        }
 166
 34167        if (nameItems.Length == 0)
 168        {
 1169            return result;
 170        }
 171
 33172        var typeNames = relatedItemKinds.Select(k => _itemTypeLookup.BaseItemKindNames[k]).ToArray();
 33173        var related = _queryHelpers.ApplyAccessFiltering(
 33174            context,
 33175            context.BaseItems.AsNoTracking().Where(e => typeNames.Contains(e.Type)),
 33176            accessFilter);
 177
 33178        var valueTypes = GetItemValueTypes(kind);
 33179        if (valueTypes.Length > 0)
 180        {
 25181            CountByItemValue(context, related, kind, relatedItemKinds, valueTypes, nameItems, result);
 182        }
 8183        else if (kind == BaseItemKind.Person)
 184        {
 3185            CountByPersonName(context, related, nameItems, result);
 186        }
 5187        else if (kind == BaseItemKind.Year)
 188        {
 5189            CountByProductionYear(related, nameItems, result);
 190        }
 191
 33192        return result;
 34193    }
 194
 195    private void CountByItemValue(
 196        JellyfinDbContext context,
 197        IQueryable<BaseItemEntity> related,
 198        BaseItemKind kind,
 199        BaseItemKind[] relatedItemKinds,
 200        ItemValueType[] valueTypes,
 201        NameItem[] nameItems,
 202        Dictionary<Guid, ItemCounts> result)
 203    {
 25204        var cleanNames = nameItems
 25205            .Select(n => n.CleanName)
 25206            .OfType<string>()
 25207            .Distinct(StringComparer.Ordinal)
 25208            .ToArray();
 209
 25210        if (cleanNames.Length == 0)
 211        {
 0212            return;
 213        }
 214
 25215        var grouped = context.ItemValuesMap.AsNoTracking()
 25216            .Where(ivm => valueTypes.Contains(ivm.ItemValue.Type))
 25217            .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue)
 25218            .Join(related, ivm => ivm.ItemId, e => e.Id, (ivm, e) => new { ivm.ItemValue.CleanValue, e.Type, e.Id })
 25219            .GroupBy(x => new { x.CleanValue, x.Type })
 25220            .Select(g => new { g.Key.CleanValue, g.Key.Type, Count = g.Select(x => x.Id).Distinct().Count() })
 25221            .ToArray();
 222
 25223        var byCleanName = grouped
 25224            .GroupBy(g => g.CleanValue, StringComparer.Ordinal)
 25225            .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray(), StringComparer.Ordinal);
 226
 25227        var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
 25228        var episodeRollUp = RollsUpEpisodes(kind, relatedItemKinds)
 25229                && Array.Exists(grouped, g => string.Equals(g.Type, seriesTypeName, StringComparison.Ordinal))
 25230            ? CountEpisodesOfTaggedSeriesByCleanName(context, related, valueTypes, cleanNames)
 25231            : null;
 232
 232233        foreach (var nameItem in nameItems)
 234        {
 91235            if (nameItem.CleanName is null || !byCleanName.TryGetValue(nameItem.CleanName, out var counts))
 236            {
 237                continue;
 238            }
 239
 27240            var itemCounts = ItemCountBuilder.Build(_itemTypeLookup, counts);
 241
 27242            if (episodeRollUp is not null)
 243            {
 8244                var rollUp = episodeRollUp.GetValueOrDefault(nameItem.CleanName);
 245
 246                // Episodes of a tagged series count towards it even when untagged themselves, and
 247                // a tagged episode of a tagged series must not be counted a second time.
 8248                var directEpisodeCount = itemCounts.EpisodeCount - rollUp.TaggedEpisodesOfTaggedSeries;
 8249                ItemCountBuilder.SetEpisodeCount(itemCounts, rollUp.EpisodesOfTaggedSeries + directEpisodeCount);
 250            }
 251
 27252            result[nameItem.Id] = itemCounts;
 253        }
 25254    }
 255
 256    private void CountByPersonName(
 257        JellyfinDbContext context,
 258        IQueryable<BaseItemEntity> related,
 259        NameItem[] nameItems,
 260        Dictionary<Guid, ItemCounts> result)
 261    {
 3262        var names = nameItems
 3263            .Select(n => n.Name)
 3264            .OfType<string>()
 3265            .Distinct(StringComparer.Ordinal)
 3266            .ToArray();
 267
 3268        if (names.Length == 0)
 269        {
 0270            return;
 271        }
 272
 3273        var grouped = context.PeopleBaseItemMap.AsNoTracking()
 3274            .WhereOneOrMany(names, m => m.People.Name)
 3275            .Join(related, m => m.ItemId, e => e.Id, (m, e) => new { m.People.Name, e.Type, e.Id })
 3276            .GroupBy(x => new { x.Name, x.Type })
 3277            // A person can be credited on one item more than once, in different roles.
 3278            .Select(g => new { g.Key.Name, g.Key.Type, Count = g.Select(x => x.Id).Distinct().Count() })
 3279            .ToArray();
 280
 3281        ApplyGroupedCounts(nameItems, n => n.Name, grouped.Select(g => (g.Name, g.Type, g.Count)), result);
 3282    }
 283
 284    private void CountByProductionYear(
 285        IQueryable<BaseItemEntity> related,
 286        NameItem[] nameItems,
 287        Dictionary<Guid, ItemCounts> result)
 288    {
 5289        var years = new List<int>();
 22290        foreach (var nameItem in nameItems)
 291        {
 6292            if (int.TryParse(nameItem.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)
 6293                && !years.Contains(year))
 294            {
 6295                years.Add(year);
 296            }
 297        }
 298
 5299        if (years.Count == 0)
 300        {
 0301            return;
 302        }
 303
 304        // No join, so no row can be reached twice and a plain count is the distinct count.
 5305        var grouped = related
 5306            .Where(e => e.ProductionYear != null)
 5307            .WhereOneOrMany(years, e => e.ProductionYear!.Value)
 5308            .GroupBy(e => new { Year = e.ProductionYear!.Value, e.Type })
 5309            .Select(g => new { g.Key.Year, g.Key.Type, Count = g.Count() })
 5310            .ToArray();
 311
 5312        var byYear = grouped
 5313            .GroupBy(g => g.Year)
 5314            .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray());
 315
 22316        foreach (var nameItem in nameItems)
 317        {
 6318            if (int.TryParse(nameItem.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)
 6319                && byYear.TryGetValue(year, out var counts))
 320            {
 6321                result[nameItem.Id] = ItemCountBuilder.Build(_itemTypeLookup, counts);
 322            }
 323        }
 5324    }
 325
 326    private void ApplyGroupedCounts(
 327        NameItem[] nameItems,
 328        Func<NameItem, string?> keySelector,
 329        IEnumerable<(string Key, string Type, int Count)> grouped,
 330        Dictionary<Guid, ItemCounts> result)
 331    {
 3332        var byKey = grouped
 3333            .GroupBy(g => g.Key, StringComparer.Ordinal)
 3334            .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray(), StringComparer.Ordinal);
 335
 14336        foreach (var nameItem in nameItems)
 337        {
 4338            var key = keySelector(nameItem);
 4339            if (key is not null && byKey.TryGetValue(key, out var counts))
 340            {
 4341                result[nameItem.Id] = ItemCountBuilder.Build(_itemTypeLookup, counts);
 342            }
 343        }
 3344    }
 345
 346    private static bool RollsUpEpisodes(BaseItemKind kind, BaseItemKind[] relatedItemKinds)
 25347        => kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre
 25348            && relatedItemKinds.Contains(BaseItemKind.Episode)
 25349            && relatedItemKinds.Contains(BaseItemKind.Series);
 350
 351    private Dictionary<string, (int EpisodesOfTaggedSeries, int TaggedEpisodesOfTaggedSeries)> CountEpisodesOfTaggedSeri
 352        JellyfinDbContext context,
 353        IQueryable<BaseItemEntity> related,
 354        ItemValueType[] valueTypes,
 355        string[] cleanNames)
 356    {
 5357        var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
 5358        var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
 359
 5360        var taggedValues = context.ItemValuesMap.AsNoTracking()
 5361            .Where(ivm => valueTypes.Contains(ivm.ItemValue.Type))
 5362            .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue);
 363
 364        // The series carrying each clean name. Distinct, because one item can be mapped to the
 365        // same clean name once per value type.
 5366        var taggedSeries = taggedValues
 5367            .Join(
 5368                related.Where(e => e.Type == seriesTypeName),
 5369                ivm => ivm.ItemId,
 5370                e => e.Id,
 5371                (ivm, e) => new { ivm.ItemValue.CleanValue, SeriesId = e.Id })
 5372            .Distinct();
 373
 5374        var episodes = related.Where(e => e.Type == episodeTypeName && e.SeriesId != null);
 375
 5376        var episodesOfTaggedSeries = taggedSeries
 5377            .Join(episodes, s => s.SeriesId, e => e.SeriesId!.Value, (s, e) => new { s.CleanValue, e.Id })
 5378            .GroupBy(x => x.CleanValue)
 5379            .Select(g => new { CleanValue = g.Key, Count = g.Select(x => x.Id).Distinct().Count() })
 5380            .ToArray();
 381
 382        // Episodes that carry the clean name themselves *and* belong to a series carrying it. The
 383        // roll-up already counts those, so they have to come off the directly tagged ones.
 5384        var taggedEpisodesOfTaggedSeries = taggedValues
 5385            .Join(episodes, ivm => ivm.ItemId, e => e.Id, (ivm, e) => new { ivm.ItemValue.CleanValue, e.Id, e.SeriesId }
 5386            .Join(
 5387                taggedSeries,
 5388                e => new { e.CleanValue, SeriesId = e.SeriesId!.Value },
 5389                s => new { s.CleanValue, s.SeriesId },
 5390                (e, s) => new { e.CleanValue, e.Id })
 5391            .GroupBy(x => x.CleanValue)
 5392            .Select(g => new { CleanValue = g.Key, Count = g.Select(x => x.Id).Distinct().Count() })
 5393            .ToArray();
 394
 5395        var taggedLookup = taggedEpisodesOfTaggedSeries
 5396            .ToDictionary(x => x.CleanValue, x => x.Count, StringComparer.Ordinal);
 397
 398        // Every clean name in taggedLookup came from an episode of a tagged series, so it always
 399        // has a row in episodesOfTaggedSeries too - no second merge pass is needed.
 5400        var result = new Dictionary<string, (int EpisodesOfTaggedSeries, int TaggedEpisodesOfTaggedSeries)>(StringCompar
 20401        foreach (var entry in episodesOfTaggedSeries)
 402        {
 5403            result[entry.CleanValue] = (entry.Count, taggedLookup.GetValueOrDefault(entry.CleanValue));
 404        }
 405
 5406        return result;
 407    }
 408
 409    /// <inheritdoc/>
 410    public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId)
 411    {
 5412        ArgumentNullException.ThrowIfNull(filter);
 5413        ArgumentNullException.ThrowIfNull(filter.User);
 5414        using var dbContext = _dbProvider.CreateDbContext();
 415
 5416        var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
 5417        return baseQuery.Count(DescendantQueryHelper.IsPlayedBy(filter.User.Id));
 5418    }
 419
 420    /// <inheritdoc/>
 421    public int GetTotalCount(InternalItemsQuery filter, Guid ancestorId)
 422    {
 3423        ArgumentNullException.ThrowIfNull(filter);
 3424        using var dbContext = _dbProvider.CreateDbContext();
 425
 3426        var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
 3427        return baseQuery.Count();
 3428    }
 429
 430    /// <inheritdoc/>
 431    public (int Played, int Total) GetPlayedAndTotalCount(InternalItemsQuery filter, Guid ancestorId)
 432    {
 5433        ArgumentNullException.ThrowIfNull(filter);
 5434        ArgumentNullException.ThrowIfNull(filter.User);
 5435        using var dbContext = _dbProvider.CreateDbContext();
 436
 5437        var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
 5438        return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id);
 5439    }
 440
 441    private IQueryable<BaseItemEntity> BuildGroupedDescendantsQuery(JellyfinDbContext dbContext, InternalItemsQuery filt
 442    {
 13443        var ancestorIds = GetPresentationKeyGroups(dbContext, [ancestorId])[ancestorId];
 13444        var descendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, ancestorIds).ToArray();
 445
 13446        var baseQuery = dbContext.BaseItems
 13447            .AsNoTracking()
 13448            .WhereOneOrMany(descendantIds, b => b.Id)
 13449            .Where(DescendantQueryHelper.IsCountableLeaf);
 450
 13451        return _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
 452    }
 453
 454    /// <inheritdoc/>
 455    public (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId)
 456    {
 1457        ArgumentNullException.ThrowIfNull(filter);
 1458        ArgumentNullException.ThrowIfNull(filter.User);
 1459        using var dbContext = _dbProvider.CreateDbContext();
 460
 1461        var allDescendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, [parentId]).ToArray();
 1462        var baseQuery = dbContext.BaseItems
 1463            .WhereOneOrMany(allDescendantIds, b => b.Id)
 1464            .Where(DescendantQueryHelper.IsCountableLeaf);
 1465        baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
 466
 1467        return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id);
 1468    }
 469
 470    /// <inheritdoc/>
 471    public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user)
 472    {
 14473        ArgumentNullException.ThrowIfNull(parentIds);
 474
 14475        if (parentIds.Count == 0)
 476        {
 0477            return new Dictionary<Guid, int>();
 478        }
 479
 14480        using var dbContext = _dbProvider.CreateDbContext();
 481
 14482        var parentIdsArray = parentIds.ToArray();
 483
 14484        var includeVirtual = user is null || user.DisplayMissingEpisodes;
 485
 14486        var accessibleItems = dbContext.BaseItems.AsNoTracking();
 14487        if (user is null)
 488        {
 489            // Access filtering is what would otherwise drop an alternate version, and a child count
 490            // must not report a title twice just because no user was passed in.
 6491            accessibleItems = accessibleItems.Where(DescendantQueryHelper.IsDistinctLibraryItem);
 492        }
 493        else
 494        {
 8495            accessibleItems = _queryHelpers.ApplyAccessFiltering(dbContext, accessibleItems, new InternalItemsQuery(user
 496        }
 497
 14498        var hierarchicalCounts = accessibleItems
 14499            .Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
 14500            .WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value)
 14501            .GroupBy(b => b.ParentId!.Value)
 14502            .Select(g => new { ParentId = g.Key, Count = g.Count() })
 14503            .ToDictionary(x => x.ParentId, x => x.Count);
 504
 505        // An episode is a child of its season even when it is not stored under one: with a flat
 506        // structure ParentId points at the series, so counting by ParentId alone leaves the season
 507        // empty and counts its episodes towards the series instead.
 14508        var seasonCounts = accessibleItems
 14509            .Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
 14510            .WhereOneOrMany(parentIdsArray, b => b.SeasonId!.Value)
 14511            .GroupBy(b => b.SeasonId!.Value)
 14512            .Select(g => new { SeasonId = g.Key, Count = g.Count() })
 14513            .ToDictionary(x => x.SeasonId, x => x.Count);
 514
 515        // A linked child counts only when the item it points at is one the user may open.
 14516        var linkedCounts = dbContext.LinkedChildren
 14517            .WhereOneOrMany(parentIdsArray, lc => lc.ParentId)
 14518            .Join(accessibleItems, lc => lc.ChildId, b => b.Id, (lc, b) => lc.ParentId)
 14519            .GroupBy(parentId => parentId)
 14520            .Select(g => new { ParentId = g.Key, Count = g.Count() })
 14521            .ToDictionary(x => x.ParentId, x => x.Count);
 522
 14523        var mergedChildCounts = GetMergedChildCounts(dbContext, accessibleItems, parentIdsArray, includeVirtual);
 524
 14525        var result = new Dictionary<Guid, int>();
 80064526        foreach (var parentId in parentIds)
 527        {
 40018528            if (mergedChildCounts.TryGetValue(parentId, out var mergedCount))
 529            {
 2530                result[parentId] = mergedCount;
 2531                continue;
 532            }
 533
 40016534            var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0)
 40016535                + seasonCounts.GetValueOrDefault(parentId, 0);
 40016536            var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0);
 537
 40016538            result[parentId] = linkedCount > 0 ? linkedCount : hierarchicalCount;
 539        }
 540
 14541        return result;
 14542    }
 543
 544    private static Dictionary<Guid, int> GetMergedChildCounts(
 545        JellyfinDbContext dbContext,
 546        IQueryable<BaseItemEntity> accessibleItems,
 547        IReadOnlyList<Guid> parentIds,
 548        bool includeVirtual)
 549    {
 14550        var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds)
 14551            .Where(group => group.Value.Count > 1)
 14552            .ToArray();
 553
 14554        if (mergedGroups.Length == 0)
 555        {
 13556            return [];
 557        }
 558
 559        // Only merged folders.
 1560        var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray();
 1561        var children = accessibleItems
 1562            .Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
 1563            .WhereOneOrMany(memberIds, b => b.ParentId!.Value)
 1564            .Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey })
 1565            .ToArray()
 1566            .Concat(accessibleItems
 1567                .Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
 1568                .WhereOneOrMany(memberIds, b => b.SeasonId!.Value)
 1569                .Select(b => new { ParentId = b.SeasonId!.Value, b.Id, b.PresentationUniqueKey })
 1570                .ToArray())
 1571            .GroupBy(b => b.ParentId)
 1572            .ToDictionary(
 1573                g => g.Key,
 1574                g => g.Select(b => string.IsNullOrEmpty(b.PresentationUniqueKey)
 1575                    ? b.Id.ToString("N", CultureInfo.InvariantCulture)
 1576                    : b.PresentationUniqueKey).ToArray());
 577
 1578        var result = new Dictionary<Guid, int>();
 6579        foreach (var (parentId, members) in mergedGroups)
 580        {
 2581            var childKeys = new HashSet<string>(StringComparer.Ordinal);
 12582            foreach (var member in members)
 583            {
 4584                if (children.TryGetValue(member, out var keys))
 585                {
 4586                    childKeys.UnionWith(keys);
 587                }
 588            }
 589
 2590            result[parentId] = childKeys.Count;
 591        }
 592
 1593        return result;
 594    }
 595
 596    /// <inheritdoc/>
 597    public Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User use
 598    {
 3599        ArgumentNullException.ThrowIfNull(folderIds);
 3600        ArgumentNullException.ThrowIfNull(user);
 601
 3602        if (folderIds.Count == 0)
 603        {
 0604            return new Dictionary<Guid, (int Played, int Total)>();
 605        }
 606
 3607        using var dbContext = _dbProvider.CreateDbContext();
 3608        var filter = new InternalItemsQuery(user);
 3609        var userId = user.Id;
 610
 611        // Merged series and seasons are stored as one row per folder-item sharing a presentation key.
 3612        var groups = GetPresentationKeyGroups(dbContext, folderIds);
 3613        var folderIdsArray = groups.Values.SelectMany(members => members).Distinct().ToArray();
 614
 3615        var leafItems = dbContext.BaseItems
 3616            .Where(DescendantQueryHelper.IsCountableLeaf);
 3617        leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter);
 618
 3619        var playedLeafItems = leafItems
 3620            .Select(DescendantQueryHelper.PlayedStateBy(userId));
 621
 3622        var ancestorLeaves = dbContext.AncestorIds
 3623            .WhereOneOrMany(folderIdsArray, a => a.ParentItemId)
 3624            .Join(
 3625                playedLeafItems,
 3626                a => a.ItemId,
 3627                b => b.Id,
 3628                (a, b) => new { FolderId = a.ParentItemId, b.Id, b.Played });
 629
 3630        var linkedLeaves = dbContext.LinkedChildren
 3631            .WhereOneOrMany(folderIdsArray, lc => lc.ParentId)
 3632            .Join(
 3633                playedLeafItems,
 3634                lc => lc.ChildId,
 3635                b => b.Id,
 3636                (lc, b) => new { FolderId = lc.ParentId, b.Id, b.Played });
 637
 3638        var linkedFolderLeaves = dbContext.LinkedChildren
 3639            .WhereOneOrMany(folderIdsArray, lc => lc.ParentId)
 3640            .Join(
 3641                dbContext.BaseItems.Where(b => b.IsFolder),
 3642                lc => lc.ChildId,
 3643                b => b.Id,
 3644                (lc, b) => new { lc.ParentId, FolderChildId = b.Id })
 3645            .Join(
 3646                dbContext.AncestorIds,
 3647                x => x.FolderChildId,
 3648                a => a.ParentItemId,
 3649                (x, a) => new { x.ParentId, DescendantId = a.ItemId })
 3650            .Join(
 3651                playedLeafItems,
 3652                x => x.DescendantId,
 3653                b => b.Id,
 3654                (x, b) => new { FolderId = x.ParentId, b.Id, b.Played });
 655
 3656        var countsByFolder = ancestorLeaves
 3657            .Union(linkedLeaves)
 3658            .Union(linkedFolderLeaves)
 3659            .GroupBy(x => x.FolderId)
 3660            .Select(g => new
 3661            {
 3662                FolderId = g.Key,
 3663                Total = g.Select(x => x.Id).Distinct().Count(),
 3664                Played = g.Where(x => x.Played).Select(x => x.Id).Distinct().Count()
 3665            })
 3666            .ToDictionary(x => x.FolderId, x => (x.Played, x.Total));
 667
 3668        var results = new Dictionary<Guid, (int Played, int Total)>();
 12669        foreach (var (folderId, members) in groups)
 670        {
 3671            var played = 0;
 3672            var total = 0;
 673
 674            // Members of a group are distinct folders, so their leaves cannot overlap.
 14675            foreach (var member in members)
 676            {
 4677                if (countsByFolder.TryGetValue(member, out var counts))
 678                {
 4679                    played += counts.Played;
 4680                    total += counts.Total;
 681                }
 682            }
 683
 3684            if (total > 0 || played > 0)
 685            {
 3686                results[folderId] = (played, total);
 687            }
 688        }
 689
 3690        return results;
 3691    }
 692
 693    private static Dictionary<Guid, List<Guid>> GetPresentationKeyGroups(JellyfinDbContext dbContext, IReadOnlyList<Guid
 694    {
 30695        var requested = dbContext.BaseItems
 30696            .AsNoTracking()
 30697            .WhereOneOrMany(folderIds, e => e.Id)
 30698            .Select(e => new { e.Id, e.PresentationUniqueKey })
 30699            .ToArray();
 700
 30701        var keys = requested
 30702            .Select(e => e.PresentationUniqueKey)
 30703            .Where(key => !string.IsNullOrEmpty(key))
 30704            .Distinct(StringComparer.Ordinal)
 30705            .ToArray();
 706
 707        // Every item that is not merged carries a key derived from its own id, so in the common case
 708        // each group resolves back to the single folder that was asked for.
 30709        var membersByKey = keys.Length == 0
 30710            ? []
 30711            : dbContext.BaseItems
 30712                .AsNoTracking()
 30713                .Where(e => e.IsFolder)
 30714                .WhereOneOrMany(keys, e => e.PresentationUniqueKey!)
 30715                .Select(e => new { e.Id, Key = e.PresentationUniqueKey! })
 30716                .ToArray()
 30717                .GroupBy(e => e.Key, StringComparer.Ordinal)
 30718                .ToDictionary(g => g.Key, g => g.Select(e => e.Id).ToList(), StringComparer.Ordinal);
 719
 30720        var keyById = requested.ToDictionary(e => e.Id, e => e.PresentationUniqueKey);
 30721        var groups = new Dictionary<Guid, List<Guid>>();
 80128722        foreach (var folderId in folderIds)
 723        {
 40034724            groups[folderId] = keyById.TryGetValue(folderId, out var key)
 40034725                && !string.IsNullOrEmpty(key)
 40034726                && membersByKey.TryGetValue(key, out var members)
 40034727                && members.Count > 0
 40034728                    ? members
 40034729                    : [folderId];
 730        }
 731
 30732        return groups;
 733    }
 734
 735    private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId
 736    {
 6737        var result = query
 6738            .Select(DescendantQueryHelper.IsPlayedBy(userId))
 6739            .GroupBy(_ => 1)
 6740            .OrderBy(g => g.Key)
 6741            .Select(g => new
 6742            {
 6743                Total = g.Count(),
 6744                Played = g.Count(isPlayed => isPlayed)
 6745            })
 6746            .FirstOrDefault();
 747
 6748        return result is null ? (0, 0) : (result.Played, result.Total);
 749    }
 750
 751    /// <summary>
 752    /// A by-name item, reduced to the three columns the counting keys off.
 753    /// </summary>
 754    /// <param name="Id">The id of the by-name item.</param>
 755    /// <param name="Name">The name of the by-name item.</param>
 756    /// <param name="CleanName">The cleaned name of the by-name item.</param>
 757    private sealed record NameItem(Guid Id, string? Name, string? CleanName);
 758}

Methods/Properties

.ctor(Microsoft.EntityFrameworkCore.IDbContextFactory`1<Jellyfin.Database.Implementations.JellyfinDbContext>,MediaBrowser.Controller.Persistence.IItemTypeLookup,MediaBrowser.Controller.Persistence.IItemQueryHelpers)
GetCount(MediaBrowser.Controller.Entities.InternalItemsQuery)
GetItemCounts(MediaBrowser.Controller.Entities.InternalItemsQuery)
GetItemCountsForNameItem(Jellyfin.Data.Enums.BaseItemKind,System.Guid,Jellyfin.Data.Enums.BaseItemKind[],MediaBrowser.Controller.Entities.InternalItemsQuery)
GetItemValueTypes(Jellyfin.Data.Enums.BaseItemKind)
GetItemCountsForNameItems(Jellyfin.Data.Enums.BaseItemKind,System.Collections.Generic.IReadOnlyList`1<System.Guid>,Jellyfin.Data.Enums.BaseItemKind[],MediaBrowser.Controller.Entities.InternalItemsQuery)
CountByItemValue(Jellyfin.Database.Implementations.JellyfinDbContext,System.Linq.IQueryable`1<Jellyfin.Database.Implementations.Entities.BaseItemEntity>,Jellyfin.Data.Enums.BaseItemKind,Jellyfin.Data.Enums.BaseItemKind[],Jellyfin.Database.Implementations.Entities.ItemValueType[],Jellyfin.Server.Implementations.Item.ItemCountService/NameItem[],System.Collections.Generic.Dictionary`2<System.Guid,MediaBrowser.Model.Dto.ItemCounts>)
CountByPersonName(Jellyfin.Database.Implementations.JellyfinDbContext,System.Linq.IQueryable`1<Jellyfin.Database.Implementations.Entities.BaseItemEntity>,Jellyfin.Server.Implementations.Item.ItemCountService/NameItem[],System.Collections.Generic.Dictionary`2<System.Guid,MediaBrowser.Model.Dto.ItemCounts>)
CountByProductionYear(System.Linq.IQueryable`1<Jellyfin.Database.Implementations.Entities.BaseItemEntity>,Jellyfin.Server.Implementations.Item.ItemCountService/NameItem[],System.Collections.Generic.Dictionary`2<System.Guid,MediaBrowser.Model.Dto.ItemCounts>)
ApplyGroupedCounts(Jellyfin.Server.Implementations.Item.ItemCountService/NameItem[],System.Func`2<Jellyfin.Server.Implementations.Item.ItemCountService/NameItem,System.String>,System.Collections.Generic.IEnumerable`1<System.ValueTuple`3<System.String,System.String,System.Int32>>,System.Collections.Generic.Dictionary`2<System.Guid,MediaBrowser.Model.Dto.ItemCounts>)
RollsUpEpisodes(Jellyfin.Data.Enums.BaseItemKind,Jellyfin.Data.Enums.BaseItemKind[])
CountEpisodesOfTaggedSeriesByCleanName(Jellyfin.Database.Implementations.JellyfinDbContext,System.Linq.IQueryable`1<Jellyfin.Database.Implementations.Entities.BaseItemEntity>,Jellyfin.Database.Implementations.Entities.ItemValueType[],System.String[])
GetPlayedCount(MediaBrowser.Controller.Entities.InternalItemsQuery,System.Guid)
GetTotalCount(MediaBrowser.Controller.Entities.InternalItemsQuery,System.Guid)
GetPlayedAndTotalCount(MediaBrowser.Controller.Entities.InternalItemsQuery,System.Guid)
BuildGroupedDescendantsQuery(Jellyfin.Database.Implementations.JellyfinDbContext,MediaBrowser.Controller.Entities.InternalItemsQuery,System.Guid)
GetPlayedAndTotalCountFromLinkedChildren(MediaBrowser.Controller.Entities.InternalItemsQuery,System.Guid)
GetChildCountBatch(System.Collections.Generic.IReadOnlyList`1<System.Guid>,Jellyfin.Database.Implementations.Entities.User)
GetMergedChildCounts(Jellyfin.Database.Implementations.JellyfinDbContext,System.Linq.IQueryable`1<Jellyfin.Database.Implementations.Entities.BaseItemEntity>,System.Collections.Generic.IReadOnlyList`1<System.Guid>,System.Boolean)
GetPlayedAndTotalCountBatch(System.Collections.Generic.IReadOnlyList`1<System.Guid>,Jellyfin.Database.Implementations.Entities.User)
GetPresentationKeyGroups(Jellyfin.Database.Implementations.JellyfinDbContext,System.Collections.Generic.IReadOnlyList`1<System.Guid>)
GetPlayedAndTotalCountFromQuery(System.Linq.IQueryable`1<Jellyfin.Database.Implementations.Entities.BaseItemEntity>,System.Guid)