using System; using Emby.Server.Implementations.Data; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Server.Implementations.Item; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Querying; using Xunit; using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; namespace Jellyfin.Server.Implementations.Tests.Item; /// /// The by-name listings count what a cleaned value is attached to by joining ItemValuesMap to /// BaseItems. One item can reach the same clean value through more than one value row, so the /// join has to be counted per distinct item; counting rows reports a multiple of the truth. /// public sealed class BaseItemRepositoryByNameItemCountsTests : SqliteDbTestFixture { private readonly BaseItemRepository _repository; private readonly ItemTypeLookup _itemTypeLookup; public BaseItemRepositoryByNameItemCountsTests() { _itemTypeLookup = new ItemTypeLookup(); _repository = CreateBaseItemRepository(_itemTypeLookup); } [Fact] public void GetAllArtists_AlbumCreditedAsArtistAndAlbumArtist_CountsTheAlbumOnce() { // GetAllArtists spans both credit types, so an album whose artist is also its album artist // reaches the one clean value through two rows. SeedArtistWithAlbum(ItemValueType.Artist, ItemValueType.AlbumArtist); var result = _repository.GetAllArtists(CreateCountingQuery()); var (_, counts) = Assert.Single(result.Items); Assert.NotNull(counts); Assert.Equal(1, counts.AlbumCount); Assert.Equal(1, counts.ItemCount); } [Fact] public void GetAlbumArtists_TwoValueRowsCleaningToOneName_CountsTheAlbumOnce() { // The shape that actually reaches users: only (Type, Value) is unique, so two differently // cased credits of one type both clean down to a single name and both map the album. SeedArtistWithAlbum(ItemValueType.AlbumArtist, ItemValueType.AlbumArtist); var result = _repository.GetAlbumArtists(CreateCountingQuery()); var (_, counts) = Assert.Single(result.Items); Assert.NotNull(counts); Assert.Equal(1, counts.AlbumCount); } [Fact] public void GetArtists_TwoValueRowsCleaningToOneName_CountsTheAlbumOnce() { SeedArtistWithAlbum(ItemValueType.Artist, ItemValueType.Artist); var result = _repository.GetArtists(CreateCountingQuery()); var (_, counts) = Assert.Single(result.Items); Assert.NotNull(counts); Assert.Equal(1, counts.AlbumCount); } [Theory] [InlineData(BaseItemKind.Book)] [InlineData(BaseItemKind.BoxSet)] public void GetGenres_TaggedBookOrBoxSet_CountsIt(BaseItemKind kind) { // The listing used to dispatch only nine of the eleven counted types, so a genre on a book // or a box set read as zero in a list and as one on the genre's own page. SeedGenreWith(kind); var result = _repository.GetGenres(CreateCountingQuery()); var (_, counts) = Assert.Single(result.Items); Assert.NotNull(counts); Assert.Equal(1, kind == BaseItemKind.Book ? counts.BookCount : counts.BoxSetCount); Assert.Equal(1, counts.ItemCount); } /// /// Seeds one genre carried by a single item of the given kind. /// /// The kind of the tagged item. private void SeedGenreWith(BaseItemKind kind) { const string Name = "Reference"; const string CleanName = "reference"; using var ctx = CreateDbContext(); var genreId = Guid.Parse("dddddddd-0000-0000-0000-000000000001"); var taggedId = Guid.Parse("eeeeeeee-0000-0000-0000-000000000001"); ctx.BaseItems.Add(new BaseItemEntity { Id = genreId, Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Genre], Name = Name, CleanName = CleanName, PresentationUniqueKey = genreId.ToString("N"), IsFolder = true, IsVirtualItem = false }); var tagged = new BaseItemEntity { Id = taggedId, Type = _itemTypeLookup.BaseItemKindNames[kind], Name = "Tagged", CleanName = "tagged", PresentationUniqueKey = taggedId.ToString("N"), IsFolder = false, IsVirtualItem = false }; ctx.BaseItems.Add(tagged); var itemValue = new ItemValue { ItemValueId = Guid.Parse("ffffffff-0000-0000-0000-000000000001"), Type = ItemValueType.Genre, Value = Name, CleanValue = CleanName }; ctx.ItemValues.Add(itemValue); ctx.ItemValuesMap.Add(new ItemValueMap { ItemId = taggedId, ItemValueId = itemValue.ItemValueId, Item = tagged, ItemValue = itemValue }); ctx.SaveChanges(); } private static InternalItemsQuery CreateCountingQuery() { return new InternalItemsQuery(new User("test", "auth", "reset")) { DtoOptions = new DtoOptions(true) { Fields = [ItemFields.ItemCounts] } }; } /// /// Seeds one artist and a single album mapped to that artist's clean name through two value /// rows of the given types. /// /// The type of the first value row. /// The type of the second value row. private void SeedArtistWithAlbum(ItemValueType first, ItemValueType second) { const string Name = "Tangerine Dream"; const string CleanName = "tangerine dream"; using var ctx = CreateDbContext(); var artistId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"); var albumId = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000001"); ctx.BaseItems.Add(new BaseItemEntity { Id = artistId, Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist], Name = Name, CleanName = CleanName, PresentationUniqueKey = artistId.ToString("N"), IsFolder = true, IsVirtualItem = false }); var album = new BaseItemEntity { Id = albumId, Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum], Name = "Phaedra", CleanName = "phaedra", PresentationUniqueKey = albumId.ToString("N"), IsFolder = true, IsVirtualItem = false }; ctx.BaseItems.Add(album); var types = new[] { first, second }; for (var i = 0; i < types.Length; i++) { var itemValue = new ItemValue { ItemValueId = Guid.Parse($"cccccccc-0000-0000-0000-{i:D12}"), Type = types[i], // Distinct values, one clean name: exactly what the unique index permits. Value = i == 0 ? Name : Name.ToUpperInvariant(), CleanValue = CleanName }; ctx.ItemValues.Add(itemValue); ctx.ItemValuesMap.Add(new ItemValueMap { ItemId = albumId, ItemValueId = itemValue.ItemValueId, Item = album, ItemValue = itemValue }); } ctx.SaveChanges(); } }