#pragma warning disable RS0030 // Do not use banned APIs using System; using System.Collections.Generic; using System.Linq; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.EntityFrameworkCore; using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem; namespace Jellyfin.Server.Implementations.Item; public sealed partial class BaseItemRepository { /// public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetAllArtists(InternalItemsQuery filter) { return GetItemValues(filter, _getAllArtistsValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]); } /// public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetArtists(InternalItemsQuery filter) { return GetItemValues(filter, _getArtistValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]); } /// public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetAlbumArtists(InternalItemsQuery filter) { return GetItemValues(filter, _getAlbumArtistValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]); } /// public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetStudios(InternalItemsQuery filter) { return GetItemValues(filter, _getStudiosValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.Studio]); } /// public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetGenres(InternalItemsQuery filter) { return GetItemValues(filter, _getGenreValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.Genre]); } /// public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetMusicGenres(InternalItemsQuery filter) { return GetItemValues(filter, _getGenreValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicGenre]); } /// public IReadOnlyList GetStudioNames() { return GetItemValueNames(_getStudiosValueTypes, [], []); } /// public IReadOnlyList GetAllArtistNames() { return GetItemValueNames(_getAllArtistsValueTypes, [], []); } /// public IReadOnlyList GetMusicGenreNames() { return GetItemValueNames( _getGenreValueTypes, _itemTypeLookup.MusicGenreTypes, []); } /// public IReadOnlyList GetGenreNames() { return GetItemValueNames( _getGenreValueTypes, [], _itemTypeLookup.MusicGenreTypes); } /// public IReadOnlyList GetMediaStreamLanguages(InternalItemsQuery filter, MediaStreamType mediaStreamType) { ArgumentNullException.ThrowIfNull(filter); using var context = _dbProvider.CreateDbContext(); return TranslateQuery( context.BaseItems.Include(e => e.MediaStreams).Where(e => e.Id != EF.Constant(PlaceholderId)), context, new InternalItemsQuery(filter.User) { IncludeOwnedItems = filter.IncludeOwnedItems, ExcludeItemTypes = filter.ExcludeItemTypes, IncludeItemTypes = filter.IncludeItemTypes, MediaTypes = filter.MediaTypes, AncestorIds = filter.AncestorIds, ItemIds = filter.ItemIds, TopParentIds = filter.TopParentIds, ParentId = filter.ParentId, IsAiring = filter.IsAiring, IsMovie = filter.IsMovie, IsSports = filter.IsSports, IsKids = filter.IsKids, IsNews = filter.IsNews, IsSeries = filter.IsSeries }) .SelectMany(e => e.MediaStreams!) .Where(e => e.StreamType == (MediaStreamTypeEntity)mediaStreamType) .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined .Distinct() .ToArray(); } private string[] GetItemValueNames(IReadOnlyList itemValueTypes, IReadOnlyList withItemTypes, IReadOnlyList excludeItemTypes) { using var context = _dbProvider.CreateDbContext(); var query = context.ItemValuesMap .AsNoTracking() .Where(e => itemValueTypes.Any(w => w == e.ItemValue.Type)); if (withItemTypes.Count > 0) { query = query.Where(e => withItemTypes.Contains(e.Item.Type)); } if (excludeItemTypes.Count > 0) { query = query.Where(e => !excludeItemTypes.Contains(e.Item.Type)); } return query.Select(e => e.ItemValue) .GroupBy(e => e.CleanValue) .Select(g => g.Min(v => v.Value)!) .ToArray(); } private QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetItemValues(InternalItemsQuery filter, IReadOnlyList itemValueTypes, string returnType) { ArgumentNullException.ThrowIfNull(filter); using var context = _dbProvider.CreateDbContext(); var innerQueryFilter = TranslateQuery(context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)), context, new InternalItemsQuery(filter.User) { ExcludeItemTypes = filter.ExcludeItemTypes, IncludeItemTypes = filter.IncludeItemTypes, MediaTypes = filter.MediaTypes, AncestorIds = filter.AncestorIds, ItemIds = filter.ItemIds, TopParentIds = filter.TopParentIds, ParentId = filter.ParentId, IsAiring = filter.IsAiring, IsMovie = filter.IsMovie, IsSports = filter.IsSports, IsKids = filter.IsKids, IsNews = filter.IsNews, IsSeries = filter.IsSeries }); var innerQuery = PrepareItemQuery(context, filter) .Where(e => e.Type == returnType) .Where(e => context.ItemValuesMap .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type) && ivm.ItemValue.CleanValue == e.CleanName) .Join( innerQueryFilter, ivm => ivm.ItemId, g => g.Id, (ivm, g) => ivm.ItemId) .Any()); var outerQueryFilter = new InternalItemsQuery(filter.User) { IsPlayed = filter.IsPlayed, IsFavorite = filter.IsFavorite, IsFavoriteOrLiked = filter.IsFavoriteOrLiked, IsLiked = filter.IsLiked, IsLocked = filter.IsLocked, NameLessThan = filter.NameLessThan, NameStartsWith = filter.NameStartsWith, NameStartsWithOrGreater = filter.NameStartsWithOrGreater, Tags = filter.Tags, OfficialRatings = filter.OfficialRatings, StudioIds = filter.StudioIds, GenreIds = filter.GenreIds, Genres = filter.Genres, Years = filter.Years, NameContains = filter.NameContains, SearchTerm = filter.SearchTerm, ExcludeItemIds = filter.ExcludeItemIds }; // Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) into one // representative id per group, then materialize the representative ids once. var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter); var isMusicArtist = returnType == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; List representativeIds; if (isMusicArtist) { // For MusicArtist, prefer the entity from a library the user can actually access. // Materialize to prevent correlated per-group first-row queries which hurt performance. var topParentIds = filter.TopParentIds; representativeIds = masterQuery .Select(e => new { e.Id, e.PresentationUniqueKey, e.TopParentId }) .AsEnumerable() .GroupBy(e => e.PresentationUniqueKey) .Select(g => g .OrderBy(e => topParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1) .ThenBy(e => e.Id) .First().Id) .ToList(); } else { representativeIds = masterQuery .GroupBy(e => e.PresentationUniqueKey) .Select(g => g.Min(e => e.Id)) .ToList(); } var result = new QueryResult<(BaseItemDto, ItemCounts?)>(); if (filter.EnableTotalRecordCount) { result.TotalRecordCount = representativeIds.Count; } var query = ApplyNavigations( context.BaseItems.AsNoTracking().AsSingleQuery().WhereOneOrMany(representativeIds, e => e.Id), filter); query = ApplyOrder(query, filter, context); if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0) { query = query.Skip(filter.StartIndex.Value); } if (filter.Limit.HasValue) { query = query.Take(filter.Limit.Value); } result.StartIndex = filter.StartIndex ?? 0; var page = query.AsEnumerable().Where(e => e is not null).ToList(); if (filter.DtoOptions.ContainsField(ItemFields.ItemCounts)) { var pageCleanNames = page .Where(e => !string.IsNullOrEmpty(e.CleanName)) .Select(e => e.CleanName!) .Distinct() .ToList(); var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes, pageCleanNames); result.Items = [ .. page .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 { result.Items = [ .. page .Select(e => DeserializeBaseItem(e, filter.SkipDeserialization)) .Where(item => item != null) .Select(item => (item!, (ItemCounts?)null)) ]; } return result; } private Dictionary BuildItemCountsByCleanName( JellyfinDbContext context, InternalItemsQuery filter, IReadOnlyList itemValueTypes, IReadOnlyList cleanNames) { var countsByCleanName = new Dictionary(); 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, MediaTypes = filter.MediaTypes, AncestorIds = filter.AncestorIds, ExcludeItemIds = filter.ExcludeItemIds, ItemIds = filter.ItemIds, TopParentIds = filter.TopParentIds, ParentId = filter.ParentId, IsPlayed = filter.IsPlayed }; 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]; // 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 = valueLinks .Join( scopedItems, ivm => ivm.ItemId, e => e.Id, (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)); foreach (var group in rawCounts.GroupBy(x => x.CleanName)) { var counts = new ItemCounts(); foreach (var row in group) { if (row.Type == seriesTypeName) { counts.SeriesCount += row.Count; } else if (row.Type == movieTypeName) { counts.MovieCount += row.Count; } else if (row.Type == musicAlbumTypeName) { counts.AlbumCount += row.Count; } else if (row.Type == musicArtistTypeName) { counts.ArtistCount += row.Count; } else if (row.Type == musicVideoTypeName) { counts.MusicVideoCount += row.Count; } else if (row.Type == programTypeName) { counts.ProgramCount += row.Count; } else if (row.Type == audioTypeName) { counts.SongCount += row.Count; } else if (row.Type == trailerTypeName) { counts.TrailerCount += row.Count; } } // 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 BuildEpisodeCountsByCleanName( IQueryable scopedItems, IQueryable 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(); var seriesByCleanName = new Dictionary>(); 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; } }