diff options
Diffstat (limited to 'Jellyfin.Server.Implementations/Item/BaseItemRepository.cs')
| -rw-r--r-- | Jellyfin.Server.Implementations/Item/BaseItemRepository.cs | 971 |
1 files changed, 673 insertions, 298 deletions
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index 7f4364cf6..2c18ce69a 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -14,6 +14,7 @@ using System.Reflection; using System.Text; using System.Text.Json; using System.Threading; +using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; @@ -54,6 +55,11 @@ public sealed class BaseItemRepository : IItemRepository { /// <summary> + /// Gets the placeholder id for UserData detached items. + /// </summary> + public static readonly Guid PlaceholderId = Guid.Parse("00000000-0000-0000-0000-000000000001"); + + /// <summary> /// This holds all the types in the running assemblies /// so that we can de-serialize properly when we don't have strong types. /// </summary> @@ -69,6 +75,7 @@ public sealed class BaseItemRepository private static readonly IReadOnlyList<ItemValueType> _getAlbumArtistValueTypes = [ItemValueType.AlbumArtist]; private static readonly IReadOnlyList<ItemValueType> _getStudiosValueTypes = [ItemValueType.Studios]; private static readonly IReadOnlyList<ItemValueType> _getGenreValueTypes = [ItemValueType.Genre]; + private static readonly IReadOnlyList<char> SearchWildcardTerms = ['%', '_', '[', ']', '^']; /// <summary> /// Initializes a new instance of the <see cref="BaseItemRepository"/> class. @@ -93,33 +100,59 @@ public sealed class BaseItemRepository } /// <inheritdoc /> - public void DeleteItem(Guid id) + public void DeleteItem(params IReadOnlyList<Guid> ids) { - if (id.IsEmpty()) + if (ids is null || ids.Count == 0 || ids.Any(f => f.Equals(PlaceholderId))) { - throw new ArgumentException("Guid can't be empty", nameof(id)); + throw new ArgumentException("Guid can't be empty or the placeholder id.", nameof(ids)); } using var context = _dbProvider.CreateDbContext(); using var transaction = context.Database.BeginTransaction(); - context.AncestorIds.Where(e => e.ItemId == id || e.ParentItemId == id).ExecuteDelete(); - context.AttachmentStreamInfos.Where(e => e.ItemId == id).ExecuteDelete(); - context.BaseItemImageInfos.Where(e => e.ItemId == id).ExecuteDelete(); - context.BaseItemMetadataFields.Where(e => e.ItemId == id).ExecuteDelete(); - context.BaseItemProviders.Where(e => e.ItemId == id).ExecuteDelete(); - context.BaseItemTrailerTypes.Where(e => e.ItemId == id).ExecuteDelete(); - context.BaseItems.Where(e => e.Id == id).ExecuteDelete(); - context.Chapters.Where(e => e.ItemId == id).ExecuteDelete(); - context.CustomItemDisplayPreferences.Where(e => e.ItemId == id).ExecuteDelete(); - context.ItemDisplayPreferences.Where(e => e.ItemId == id).ExecuteDelete(); + + var date = (DateTime?)DateTime.UtcNow; + + var relatedItems = ids.SelectMany(f => TraverseHirachyDown(f, context)).ToArray(); + + // Remove any UserData entries for the placeholder item that would conflict with the UserData + // being detached from the item being deleted. This is necessary because, during an update, + // UserData may be reattached to a new entry, but some entries can be left behind. + // Ensures there are no duplicate UserId/CustomDataKey combinations for the placeholder. + context.UserData + .Join( + context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId), + placeholder => new { placeholder.UserId, placeholder.CustomDataKey }, + userData => new { userData.UserId, userData.CustomDataKey }, + (placeholder, userData) => placeholder) + .Where(e => e.ItemId == PlaceholderId) + .ExecuteDelete(); + + // Detach all user watch data + context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId) + .ExecuteUpdate(e => e + .SetProperty(f => f.RetentionDate, date) + .SetProperty(f => f.ItemId, PlaceholderId)); + + context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ParentItemId).ExecuteDelete(); + context.AttachmentStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.BaseItemImageInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.BaseItemMetadataFields.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.BaseItemProviders.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.BaseItemTrailerTypes.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDelete(); + context.Chapters.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.CustomItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.ItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDelete(); - context.ItemValuesMap.Where(e => e.ItemId == id).ExecuteDelete(); - context.KeyframeData.Where(e => e.ItemId == id).ExecuteDelete(); - context.MediaSegments.Where(e => e.ItemId == id).ExecuteDelete(); - context.MediaStreamInfos.Where(e => e.ItemId == id).ExecuteDelete(); - context.PeopleBaseItemMap.Where(e => e.ItemId == id).ExecuteDelete(); - context.Peoples.Where(e => e.BaseItems!.Count == 0).ExecuteDelete(); - context.TrickplayInfos.Where(e => e.ItemId == id).ExecuteDelete(); + context.ItemValuesMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.KeyframeData.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.MediaSegments.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.MediaStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + var query = context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).Select(f => f.PeopleId).Distinct().ToArray(); + context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); + context.Peoples.WhereOneOrMany(query, e => e.Id).Where(e => e.BaseItems!.Count == 0).ExecuteDelete(); + context.TrickplayInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete(); context.SaveChanges(); transaction.Commit(); } @@ -144,41 +177,41 @@ public sealed class BaseItemRepository PrepareFilterQuery(filter); using var context = _dbProvider.CreateDbContext(); - return ApplyQueryFilter(context.BaseItems.AsNoTracking(), context, filter).Select(e => e.Id).ToArray(); + return ApplyQueryFilter(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, filter).Select(e => e.Id).ToArray(); } /// <inheritdoc /> - public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery filter) + public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetAllArtists(InternalItemsQuery filter) { return GetItemValues(filter, _getAllArtistsValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]); } /// <inheritdoc /> - public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery filter) + public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetArtists(InternalItemsQuery filter) { return GetItemValues(filter, _getArtistValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]); } /// <inheritdoc /> - public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery filter) + public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetAlbumArtists(InternalItemsQuery filter) { return GetItemValues(filter, _getAlbumArtistValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]); } /// <inheritdoc /> - public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetStudios(InternalItemsQuery filter) + public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetStudios(InternalItemsQuery filter) { return GetItemValues(filter, _getStudiosValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.Studio]); } /// <inheritdoc /> - public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery filter) + public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetGenres(InternalItemsQuery filter) { return GetItemValues(filter, _getGenreValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.Genre]); } /// <inheritdoc /> - public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery filter) + public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetMusicGenres(InternalItemsQuery filter) { return GetItemValues(filter, _getGenreValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicGenre]); } @@ -234,15 +267,17 @@ public sealed class BaseItemRepository IQueryable<BaseItemEntity> dbQuery = PrepareItemQuery(context, filter); dbQuery = TranslateQuery(dbQuery, context, filter); + dbQuery = ApplyGroupingFilter(context, dbQuery, filter); + if (filter.EnableTotalRecordCount) { result.TotalRecordCount = dbQuery.Count(); } - dbQuery = ApplyGroupingFilter(dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter); + dbQuery = ApplyNavigations(dbQuery, filter); - result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserialiseBaseItem(w, filter.SkipDeserialization)).ToArray(); + result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); result.StartIndex = filter.StartIndex ?? 0; return result; } @@ -258,10 +293,11 @@ public sealed class BaseItemRepository dbQuery = TranslateQuery(dbQuery, context, filter); - dbQuery = ApplyGroupingFilter(dbQuery, filter); + dbQuery = ApplyGroupingFilter(context, dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter); + dbQuery = ApplyNavigations(dbQuery, filter); - return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserialiseBaseItem(w, filter.SkipDeserialization)).ToArray(); + return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); } /// <inheritdoc/> @@ -300,10 +336,12 @@ public sealed class BaseItemRepository var mainquery = PrepareItemQuery(context, filter); mainquery = TranslateQuery(mainquery, context, filter); mainquery = mainquery.Where(g => g.DateCreated >= subqueryGrouped.Min(s => s.MaxDateCreated)); - mainquery = ApplyGroupingFilter(mainquery, filter); + mainquery = ApplyGroupingFilter(context, mainquery, filter); mainquery = ApplyQueryPaging(mainquery, filter); - return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserialiseBaseItem(w, filter.SkipDeserialization)).ToArray(); + mainquery = ApplyNavigations(mainquery, filter); + + return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); } /// <inheritdoc /> @@ -319,7 +357,7 @@ public sealed class BaseItemRepository .Where(i => filter.TopParentIds.Contains(i.TopParentId!.Value)) .Where(i => i.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]) .Join( - context.UserData.AsNoTracking(), + context.UserData.AsNoTracking().Where(e => e.ItemId != EF.Constant(PlaceholderId)), i => new { UserId = filter.User.Id, ItemId = i.Id }, u => new { UserId = u.UserId, ItemId = u.ItemId }, (entity, data) => new { Item = entity, UserData = data }) @@ -337,36 +375,50 @@ public sealed class BaseItemRepository return query.ToArray(); } - private IQueryable<BaseItemEntity> ApplyGroupingFilter(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) + private IQueryable<BaseItemEntity> ApplyGroupingFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) { // This whole block is needed to filter duplicate entries on request // for the time being it cannot be used because it would destroy the ordering // this results in "duplicate" responses for queries that try to lookup individual series or multiple versions but // for that case the invoker has to run a DistinctBy(e => e.PresentationUniqueKey) on their own - // var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter); - // if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey) - // { - // dbQuery = ApplyOrder(dbQuery, filter); - // dbQuery = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }).Select(e => e.First()); - // } - // else if (enableGroupByPresentationUniqueKey) - // { - // dbQuery = ApplyOrder(dbQuery, filter); - // dbQuery = dbQuery.GroupBy(e => e.PresentationUniqueKey).Select(e => e.First()); - // } - // else if (filter.GroupBySeriesPresentationUniqueKey) - // { - // dbQuery = ApplyOrder(dbQuery, filter); - // dbQuery = dbQuery.GroupBy(e => e.SeriesPresentationUniqueKey).Select(e => e.First()); - // } - // else - // { - // dbQuery = dbQuery.Distinct(); - // dbQuery = ApplyOrder(dbQuery, filter); - // } - dbQuery = dbQuery.Distinct(); - dbQuery = ApplyOrder(dbQuery, filter); + var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter); + if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey) + { + var tempQuery = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }).Select(e => e.FirstOrDefault()).Select(e => e!.Id); + dbQuery = context.BaseItems.Where(e => tempQuery.Contains(e.Id)); + } + else if (enableGroupByPresentationUniqueKey) + { + var tempQuery = dbQuery.GroupBy(e => e.PresentationUniqueKey).Select(e => e.FirstOrDefault()).Select(e => e!.Id); + dbQuery = context.BaseItems.Where(e => tempQuery.Contains(e.Id)); + } + else if (filter.GroupBySeriesPresentationUniqueKey) + { + var tempQuery = dbQuery.GroupBy(e => e.SeriesPresentationUniqueKey).Select(e => e.FirstOrDefault()).Select(e => e!.Id); + dbQuery = context.BaseItems.Where(e => tempQuery.Contains(e.Id)); + } + else + { + dbQuery = dbQuery.Distinct(); + } + + dbQuery = ApplyOrder(dbQuery, filter, context); + + return dbQuery; + } + + private static IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) + { + dbQuery = dbQuery.Include(e => e.TrailerTypes) + .Include(e => e.Provider) + .Include(e => e.LockedFields) + .Include(e => e.UserData); + + if (filter.DtoOptions.EnableImages) + { + dbQuery = dbQuery.Include(e => e.Images); + } return dbQuery; } @@ -394,23 +446,16 @@ public sealed class BaseItemRepository private IQueryable<BaseItemEntity> ApplyQueryFilter(IQueryable<BaseItemEntity> dbQuery, JellyfinDbContext context, InternalItemsQuery filter) { dbQuery = TranslateQuery(dbQuery, context, filter); - dbQuery = ApplyOrder(dbQuery, filter); - dbQuery = ApplyGroupingFilter(dbQuery, filter); + dbQuery = ApplyGroupingFilter(context, dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter); + dbQuery = ApplyNavigations(dbQuery, filter); return dbQuery; } private IQueryable<BaseItemEntity> PrepareItemQuery(JellyfinDbContext context, InternalItemsQuery filter) { - IQueryable<BaseItemEntity> dbQuery = context.BaseItems.AsNoTracking().AsSplitQuery() - .Include(e => e.TrailerTypes) - .Include(e => e.Provider) - .Include(e => e.LockedFields); - - if (filter.DtoOptions.EnableImages) - { - dbQuery = dbQuery.Include(e => e.Images); - } + IQueryable<BaseItemEntity> dbQuery = context.BaseItems.AsNoTracking(); + dbQuery = dbQuery.AsSingleQuery(); return dbQuery; } @@ -428,6 +473,66 @@ public sealed class BaseItemRepository return dbQuery.Count(); } + /// <inheritdoc /> + public ItemCounts GetItemCounts(InternalItemsQuery filter) + { + ArgumentNullException.ThrowIfNull(filter); + // Hack for right now since we currently don't support filtering out these duplicates within a query + PrepareFilterQuery(filter); + + using var context = _dbProvider.CreateDbContext(); + var dbQuery = TranslateQuery(context.BaseItems.AsNoTracking(), context, filter); + + var counts = dbQuery + .GroupBy(x => x.Type) + .Select(x => new { x.Key, Count = x.Count() }) + .ToArray(); + + var lookup = _itemTypeLookup.BaseItemKindNames; + var result = new ItemCounts(); + foreach (var count in counts) + { + if (string.Equals(count.Key, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal)) + { + result.AlbumCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal)) + { + result.ArtistCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.Episode], StringComparison.Ordinal)) + { + result.EpisodeCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.Movie], StringComparison.Ordinal)) + { + result.MovieCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal)) + { + result.MusicVideoCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal)) + { + result.ProgramCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.Series], StringComparison.Ordinal)) + { + result.SeriesCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.Audio], StringComparison.Ordinal)) + { + result.SongCount = count.Count; + } + else if (string.Equals(count.Key, lookup[BaseItemKind.Trailer], StringComparison.Ordinal)) + { + result.TrailerCount = count.Count; + } + } + + return result; + } + #pragma warning disable CA1307 // Specify StringComparison for clarity /// <summary> /// Gets the type. @@ -453,11 +558,16 @@ public sealed class BaseItemRepository var images = item.ImageInfos.Select(e => Map(item.Id, e)); using var context = _dbProvider.CreateDbContext(); - using var transaction = context.Database.BeginTransaction(); + + if (!context.BaseItems.Any(bi => bi.Id == item.Id)) + { + _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem"); + return; + } + context.BaseItemImageInfos.Where(e => e.ItemId == item.Id).ExecuteDelete(); context.BaseItemImageInfos.AddRange(images); context.SaveChanges(); - transaction.Commit(); } /// <inheritdoc /> @@ -473,7 +583,7 @@ public sealed class BaseItemRepository cancellationToken.ThrowIfCancellationRequested(); var tuples = new List<(BaseItemDto Item, List<Guid>? AncestorIds, BaseItemDto TopParent, IEnumerable<string> UserDataKey, List<string> InheritedTags)>(); - foreach (var item in items.GroupBy(e => e.Id).Select(e => e.Last())) + foreach (var item in items.GroupBy(e => e.Id).Select(e => e.Last()).Where(e => e.Id != PlaceholderId)) { var ancestorIds = item.SupportsAncestors ? item.GetAncestorIds().Distinct().ToList() : @@ -487,78 +597,140 @@ public sealed class BaseItemRepository tuples.Add((item, ancestorIds, topParent, userdataKey, inheritedTags)); } - var localItemValueCache = new Dictionary<(int MagicNumber, string Value), Guid>(); - using var context = _dbProvider.CreateDbContext(); using var transaction = context.Database.BeginTransaction(); + + var ids = tuples.Select(f => f.Item.Id).ToArray(); + var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray(); + var newItems = tuples.Where(e => !existingItems.Contains(e.Item.Id)).ToArray(); + foreach (var item in tuples) { var entity = Map(item.Item); // TODO: refactor this "inconsistency" entity.TopParentId = item.TopParent?.Id; - if (!context.BaseItems.Any(e => e.Id == entity.Id)) + if (!existingItems.Any(e => e == entity.Id)) { context.BaseItems.Add(entity); } else { context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete(); + context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete(); + + if (entity.Images is { Count: > 0 }) + { + context.BaseItemImageInfos.AddRange(entity.Images); + } + context.BaseItems.Attach(entity).State = EntityState.Modified; } + } - context.AncestorIds.Where(e => e.ItemId == entity.Id).ExecuteDelete(); - if (item.Item.SupportsAncestors && item.AncestorIds != null) + context.SaveChanges(); + + foreach (var item in newItems) + { + // reattach old userData entries + var userKeys = item.UserDataKey.ToArray(); + var retentionDate = (DateTime?)null; + context.UserData + .Where(e => e.ItemId == PlaceholderId) + .Where(e => userKeys.Contains(e.CustomDataKey)) + .ExecuteUpdate(e => e + .SetProperty(f => f.ItemId, item.Item.Id) + .SetProperty(f => f.RetentionDate, retentionDate)); + } + + var itemValueMaps = tuples + .Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags))) + .ToArray(); + var allListedItemValues = itemValueMaps + .SelectMany(f => f.Values) + .Distinct() + .ToArray(); + var existingValues = context.ItemValues + .Select(e => new { - foreach (var ancestorId in item.AncestorIds) - { - if (!context.BaseItems.Any(f => f.Id == ancestorId)) - { - continue; - } + item = e, + Key = e.Type + "+" + e.Value + }) + .Where(f => allListedItemValues.Select(e => $"{(int)e.MagicNumber}+{e.Value}").Contains(f.Key)) + .Select(e => e.item) + .ToArray(); + var missingItemValues = allListedItemValues.Except(existingValues.Select(f => (MagicNumber: f.Type, f.Value))).Select(f => new ItemValue() + { + CleanValue = GetCleanValue(f.Value), + ItemValueId = Guid.NewGuid(), + Type = f.MagicNumber, + Value = f.Value + }).ToArray(); + context.ItemValues.AddRange(missingItemValues); + context.SaveChanges(); + + var itemValuesStore = existingValues.Concat(missingItemValues).ToArray(); + var valueMap = itemValueMaps + .Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore.First(g => g.Value == e.Value && g.Type == e.MagicNumber)).DistinctBy(e => e.ItemValueId).ToArray())) + .ToArray(); - context.AncestorIds.Add(new AncestorId() + var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList(); + + foreach (var item in valueMap) + { + var itemMappedValues = mappedValues.Where(e => e.ItemId == item.Item.Id).ToList(); + foreach (var itemValue in item.Values) + { + var existingItem = itemMappedValues.FirstOrDefault(f => f.ItemValueId == itemValue.ItemValueId); + if (existingItem is null) + { + context.ItemValuesMap.Add(new ItemValueMap() { - ParentItemId = ancestorId, - ItemId = entity.Id, Item = null!, - ParentItem = null! + ItemId = item.Item.Id, + ItemValue = null!, + ItemValueId = itemValue.ItemValueId }); } - } - - // Never save duplicate itemValues as they are now mapped anyway. - var itemValuesToSave = GetItemValuesToSave(item.Item, item.InheritedTags).DistinctBy(e => (GetCleanValue(e.Value), e.MagicNumber)); - context.ItemValuesMap.Where(e => e.ItemId == entity.Id).ExecuteDelete(); - foreach (var itemValue in itemValuesToSave) - { - if (!localItemValueCache.TryGetValue(itemValue, out var refValue)) + else { - refValue = context.ItemValues - .Where(f => f.Value == itemValue.Value && (int)f.Type == itemValue.MagicNumber) - .Select(e => e.ItemValueId) - .FirstOrDefault(); + // map exists, remove from list so its been handled. + itemMappedValues.Remove(existingItem); } + } + + // all still listed values are not in the new list so remove them. + context.ItemValuesMap.RemoveRange(itemMappedValues); + } + + context.SaveChanges(); - if (refValue.IsEmpty()) + foreach (var item in tuples) + { + if (item.Item.SupportsAncestors && item.AncestorIds != null) + { + var existingAncestorIds = context.AncestorIds.Where(e => e.ItemId == item.Item.Id).ToList(); + var validAncestorIds = context.BaseItems.Where(e => item.AncestorIds.Contains(e.Id)).Select(f => f.Id).ToArray(); + foreach (var ancestorId in validAncestorIds) { - context.ItemValues.Add(new ItemValue() + var existingAncestorId = existingAncestorIds.FirstOrDefault(e => e.ParentItemId == ancestorId); + if (existingAncestorId is null) { - CleanValue = GetCleanValue(itemValue.Value), - Type = (ItemValueType)itemValue.MagicNumber, - ItemValueId = refValue = Guid.NewGuid(), - Value = itemValue.Value - }); - localItemValueCache[itemValue] = refValue; + context.AncestorIds.Add(new AncestorId() + { + ParentItemId = ancestorId, + ItemId = item.Item.Id, + Item = null!, + ParentItem = null! + }); + } + else + { + existingAncestorIds.Remove(existingAncestorId); + } } - context.ItemValuesMap.Add(new ItemValueMap() - { - Item = null!, - ItemId = entity.Id, - ItemValue = null!, - ItemValueId = refValue - }); + context.AncestorIds.RemoveRange(existingAncestorIds); } } @@ -575,19 +747,26 @@ public sealed class BaseItemRepository } using var context = _dbProvider.CreateDbContext(); - var item = PrepareItemQuery(context, new() + var dbQuery = PrepareItemQuery(context, new() { DtoOptions = new() { EnableImages = true } - }).FirstOrDefault(e => e.Id == id); + }); + dbQuery = dbQuery.Include(e => e.TrailerTypes) + .Include(e => e.Provider) + .Include(e => e.LockedFields) + .Include(e => e.UserData) + .Include(e => e.Images); + + var item = dbQuery.FirstOrDefault(e => e.Id == id); if (item is null) { return null; } - return DeserialiseBaseItem(item); + return DeserializeBaseItem(item); } /// <summary> @@ -596,8 +775,9 @@ public sealed class BaseItemRepository /// <param name="entity">The entity.</param> /// <param name="dto">The dto base instance.</param> /// <param name="appHost">The Application server Host.</param> + /// <param name="logger">The applogger.</param> /// <returns>The dto to map.</returns> - public static BaseItemDto Map(BaseItemEntity entity, BaseItemDto dto, IServerApplicationHost? appHost) + public static BaseItemDto Map(BaseItemEntity entity, BaseItemDto dto, IServerApplicationHost? appHost, ILogger logger) { dto.Id = entity.Id; dto.ParentId = entity.ParentId.GetValueOrDefault(); @@ -633,15 +813,17 @@ public sealed class BaseItemRepository dto.TotalBitrate = entity.TotalBitrate; dto.ExternalId = entity.ExternalId; dto.Size = entity.Size; - dto.Genres = entity.Genres?.Split('|') ?? []; - dto.DateCreated = entity.DateCreated.GetValueOrDefault(); - dto.DateModified = entity.DateModified.GetValueOrDefault(); + dto.Genres = string.IsNullOrWhiteSpace(entity.Genres) ? [] : entity.Genres.Split('|'); + dto.DateCreated = entity.DateCreated ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); + dto.DateModified = entity.DateModified ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); dto.ChannelId = entity.ChannelId ?? Guid.Empty; - dto.DateLastRefreshed = entity.DateLastRefreshed.GetValueOrDefault(); - dto.DateLastSaved = entity.DateLastSaved.GetValueOrDefault(); + dto.DateLastRefreshed = entity.DateLastRefreshed ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); + dto.DateLastSaved = entity.DateLastSaved ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); dto.OwnerId = string.IsNullOrWhiteSpace(entity.OwnerId) ? Guid.Empty : (Guid.TryParse(entity.OwnerId, out var ownerId) ? ownerId : Guid.Empty); dto.Width = entity.Width.GetValueOrDefault(); dto.Height = entity.Height.GetValueOrDefault(); + dto.UserData = entity.UserData; + if (entity.Provider is not null) { dto.ProviderIds = entity.Provider.ToDictionary(e => e.ProviderId, e => e.ProviderValue); @@ -665,7 +847,7 @@ public sealed class BaseItemRepository dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray(); dto.ProductionLocations = entity.ProductionLocations?.Split('|') ?? []; dto.Studios = entity.Studios?.Split('|') ?? []; - dto.Tags = entity.Tags?.Split('|') ?? []; + dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|'); if (dto is IHasProgramAttributes hasProgramAttributes) { @@ -739,7 +921,7 @@ public sealed class BaseItemRepository if (dto is Folder folder) { - folder.DateLastMediaAdded = entity.DateLastMediaAdded; + folder.DateLastMediaAdded = entity.DateLastMediaAdded ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); } return dto; @@ -799,11 +981,11 @@ public sealed class BaseItemRepository entity.ExternalId = dto.ExternalId; entity.Size = dto.Size; entity.Genres = string.Join('|', dto.Genres); - entity.DateCreated = dto.DateCreated; - entity.DateModified = dto.DateModified; + entity.DateCreated = dto.DateCreated == DateTime.MinValue ? null : dto.DateCreated; + entity.DateModified = dto.DateModified == DateTime.MinValue ? null : dto.DateModified; entity.ChannelId = dto.ChannelId; - entity.DateLastRefreshed = dto.DateLastRefreshed; - entity.DateLastSaved = dto.DateLastSaved; + entity.DateLastRefreshed = dto.DateLastRefreshed == DateTime.MinValue ? null : dto.DateLastRefreshed; + entity.DateLastSaved = dto.DateLastSaved == DateTime.MinValue ? null : dto.DateLastSaved; entity.OwnerId = dto.OwnerId.ToString(); entity.Width = dto.Width; entity.Height = dto.Height; @@ -913,7 +1095,7 @@ public sealed class BaseItemRepository if (dto is Folder folder) { - entity.DateLastMediaAdded = folder.DateLastMediaAdded; + entity.DateLastMediaAdded = folder.DateLastMediaAdded == DateTime.MinValue ? null : folder.DateLastMediaAdded; entity.IsFolder = folder.IsFolder; } @@ -949,7 +1131,7 @@ public sealed class BaseItemRepository return type.GetCustomAttribute<RequiresSourceSerialisationAttribute>() == null; } - private BaseItemDto DeserialiseBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false) + private BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false) { ArgumentNullException.ThrowIfNull(baseItemEntity, nameof(baseItemEntity)); if (_serverConfigurationManager?.Configuration is null) @@ -958,7 +1140,7 @@ public sealed class BaseItemRepository } var typeToSerialise = GetType(baseItemEntity.Type); - return BaseItemRepository.DeserialiseBaseItem( + return BaseItemRepository.DeserializeBaseItem( baseItemEntity, _logger, _appHost, @@ -966,7 +1148,7 @@ public sealed class BaseItemRepository } /// <summary> - /// Deserialises a BaseItemEntity and sets all properties. + /// Deserializes a BaseItemEntity and sets all properties. /// </summary> /// <param name="baseItemEntity">The DB entity.</param> /// <param name="logger">Logger.</param> @@ -974,9 +1156,9 @@ public sealed class BaseItemRepository /// <param name="skipDeserialization">If only mapping should be processed.</param> /// <returns>A mapped BaseItem.</returns> /// <exception cref="InvalidOperationException">Will be thrown if an invalid serialisation is requested.</exception> - public static BaseItemDto DeserialiseBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false) + public static BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false) { - var type = GetType(baseItemEntity.Type) ?? throw new InvalidOperationException("Cannot deserialise unknown type."); + var type = GetType(baseItemEntity.Type) ?? throw new InvalidOperationException("Cannot deserialize unknown type."); BaseItemDto? dto = null; if (TypeRequiresDeserialization(type) && baseItemEntity.Data is not null && !skipDeserialization) { @@ -992,13 +1174,13 @@ public sealed class BaseItemRepository if (dto is null) { - dto = Activator.CreateInstance(type) as BaseItemDto ?? throw new InvalidOperationException("Cannot deserialise unknown type."); + dto = Activator.CreateInstance(type) as BaseItemDto ?? throw new InvalidOperationException("Cannot deserialize unknown type."); } - return Map(baseItemEntity, dto, appHost); + return Map(baseItemEntity, dto, appHost, logger); } - private QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetItemValues(InternalItemsQuery filter, IReadOnlyList<ItemValueType> itemValueTypes, string returnType) + private QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetItemValues(InternalItemsQuery filter, IReadOnlyList<ItemValueType> itemValueTypes, string returnType) { ArgumentNullException.ThrowIfNull(filter); @@ -1009,20 +1191,76 @@ public sealed class BaseItemRepository using var context = _dbProvider.CreateDbContext(); - var query = TranslateQuery(context.BaseItems.AsNoTracking(), context, filter); + 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 + }); - query = query.Where(e => e.Type == returnType); - // this does not seem to be nesseary but it does not make any sense why this isn't working. - // && e.ItemValues!.Any(f => e.CleanName == f.ItemValue.CleanValue && itemValueTypes.Any(w => (ItemValueType)w == f.ItemValue.Type))); + var itemValuesQuery = context.ItemValues + .Where(f => itemValueTypes.Contains(f.Type)) + .SelectMany(f => f.BaseItemsMap!, (f, w) => new { f, w }) + .Join( + innerQueryFilter, + fw => fw.w.ItemId, + g => g.Id, + (fw, g) => fw.f.CleanValue); + + var innerQuery = PrepareItemQuery(context, filter) + .Where(e => e.Type == returnType) + .Where(e => itemValuesQuery.Contains(e.CleanName)); + + 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 + }; - if (filter.OrderBy.Count != 0 - || !string.IsNullOrEmpty(filter.SearchTerm)) - { - query = ApplyOrder(query, filter); - } - else + var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter) + .GroupBy(e => e.PresentationUniqueKey) + .Select(e => e.FirstOrDefault()) + .Select(e => e!.Id); + + var query = context.BaseItems + .Include(e => e.TrailerTypes) + .Include(e => e.Provider) + .Include(e => e.LockedFields) + .Include(e => e.Images) + .AsSingleQuery() + .Where(e => masterQuery.Contains(e.Id)); + + query = ApplyOrder(query, filter, context); + + var result = new QueryResult<(BaseItemDto, ItemCounts?)>(); + if (filter.EnableTotalRecordCount) { - query = query.OrderBy(e => e.SortName); + result.TotalRecordCount = query.Count(); } if (filter.Limit.HasValue || filter.StartIndex.HasValue) @@ -1040,41 +1278,78 @@ public sealed class BaseItemRepository } } - var result = new QueryResult<(BaseItemDto, ItemCounts)>(); - if (filter.EnableTotalRecordCount) - { - result.TotalRecordCount = query.GroupBy(e => e.PresentationUniqueKey).Select(e => e.First()).Count(); - } - - 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 audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; - var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; + IQueryable<BaseItemEntity>? itemCountQuery = null; - var resultQuery = query.Select(e => new + if (filter.IncludeItemTypes.Length > 0) { - item = e, - // TODO: This is bad refactor! - itemCount = new ItemCounts() + // if we are to include more then one type, sub query those items beforehand. + + var typeSubQuery = new InternalItemsQuery(filter.User) { - SeriesCount = e.ItemValues!.Count(f => f.Item.Type == seriesTypeName), - EpisodeCount = e.ItemValues!.Count(f => f.Item.Type == episodeTypeName), - MovieCount = e.ItemValues!.Count(f => f.Item.Type == movieTypeName), - AlbumCount = e.ItemValues!.Count(f => f.Item.Type == musicAlbumTypeName), - ArtistCount = e.ItemValues!.Count(f => f.Item.Type == musicArtistTypeName), - SongCount = e.ItemValues!.Count(f => f.Item.Type == audioTypeName), - TrailerCount = e.ItemValues!.Count(f => f.Item.Type == trailerTypeName), - } - }); + ExcludeItemTypes = filter.ExcludeItemTypes, + IncludeItemTypes = filter.IncludeItemTypes, + MediaTypes = filter.MediaTypes, + AncestorIds = filter.AncestorIds, + ExcludeItemIds = filter.ExcludeItemIds, + ItemIds = filter.ItemIds, + TopParentIds = filter.TopParentIds, + ParentId = filter.ParentId, + IsPlayed = filter.IsPlayed + }; - result.StartIndex = filter.StartIndex ?? 0; - result.Items = resultQuery.ToArray().Where(e => e is not null).Select(e => + 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 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 audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; + var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; + + var resultQuery = query.Select(e => new + { + item = e, + // TODO: This is bad refactor! + itemCount = new ItemCounts() + { + SeriesCount = itemCountQuery!.Count(f => f.Type == seriesTypeName), + EpisodeCount = itemCountQuery!.Count(f => f.Type == episodeTypeName), + MovieCount = itemCountQuery!.Count(f => f.Type == movieTypeName), + AlbumCount = itemCountQuery!.Count(f => f.Type == musicAlbumTypeName), + ArtistCount = itemCountQuery!.Count(f => f.Type == musicArtistTypeName), + SongCount = itemCountQuery!.Count(f => f.Type == audioTypeName), + TrailerCount = itemCountQuery!.Count(f => f.Type == trailerTypeName), + } + }); + + result.StartIndex = filter.StartIndex ?? 0; + result.Items = + [ + .. resultQuery + .AsEnumerable() + .Where(e => e is not null) + .Select(e => + { + return (DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount); + }) + ]; + } + else { - return (DeserialiseBaseItem(e.item, filter.SkipDeserialization), e.itemCount); - }).ToArray(); + result.StartIndex = filter.StartIndex ?? 0; + result.Items = + [ + .. query + .AsEnumerable() + .Where(e => e is not null) + .Select<BaseItemEntity, (BaseItemDto, ItemCounts?)>(e => + { + return (DeserializeBaseItem(e, filter.SkipDeserialization), null); + }) + ]; + } return result; } @@ -1102,27 +1377,27 @@ public sealed class BaseItemRepository return value.RemoveDiacritics().ToLowerInvariant(); } - private List<(int MagicNumber, string Value)> GetItemValuesToSave(BaseItemDto item, List<string> inheritedTags) + private List<(ItemValueType MagicNumber, string Value)> GetItemValuesToSave(BaseItemDto item, List<string> inheritedTags) { - var list = new List<(int, string)>(); + var list = new List<(ItemValueType, string)>(); if (item is IHasArtist hasArtist) { - list.AddRange(hasArtist.Artists.Select(i => (0, i))); + list.AddRange(hasArtist.Artists.Select(i => ((ItemValueType)0, i))); } if (item is IHasAlbumArtist hasAlbumArtist) { - list.AddRange(hasAlbumArtist.AlbumArtists.Select(i => (1, i))); + list.AddRange(hasAlbumArtist.AlbumArtists.Select(i => (ItemValueType.AlbumArtist, i))); } - list.AddRange(item.Genres.Select(i => (2, i))); - list.AddRange(item.Studios.Select(i => (3, i))); - list.AddRange(item.Tags.Select(i => (4, i))); + list.AddRange(item.Genres.Select(i => (ItemValueType.Genre, i))); + list.AddRange(item.Studios.Select(i => (ItemValueType.Studios, i))); + list.AddRange(item.Tags.Select(i => (ItemValueType.Tags, i))); // keywords was 5 - list.AddRange(inheritedTags.Select(i => (6, i))); + list.AddRange(inheritedTags.Select(i => (ItemValueType.InheritedTags, i))); // Remove all invalid values. list.RemoveAll(i => string.IsNullOrWhiteSpace(i.Item2)); @@ -1152,7 +1427,7 @@ public sealed class BaseItemRepository { Path = appHost?.ExpandVirtualPath(e.Path) ?? e.Path, BlurHash = e.Blurhash is null ? null : Encoding.UTF8.GetString(e.Blurhash), - DateModified = e.DateModified, + DateModified = e.DateModified ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc), Height = e.Height, Width = e.Width, Type = (ImageType)e.ImageType @@ -1246,7 +1521,7 @@ public sealed class BaseItemRepository || query.IncludeItemTypes.Contains(BaseItemKind.Season); } - private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter) + private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter, JellyfinDbContext context) { var orderBy = filter.OrderBy; var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm); @@ -1257,7 +1532,7 @@ public sealed class BaseItemRepository } else if (orderBy.Count == 0) { - return query; + return query.OrderBy(e => e.SortName); } IOrderedQueryable<BaseItemEntity>? orderedQuery = null; @@ -1265,7 +1540,7 @@ public sealed class BaseItemRepository var firstOrdering = orderBy.FirstOrDefault(); if (firstOrdering != default) { - var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter); + var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context); if (firstOrdering.SortOrder == SortOrder.Ascending) { orderedQuery = query.OrderBy(expression); @@ -1290,7 +1565,7 @@ public sealed class BaseItemRepository foreach (var item in orderBy.Skip(1)) { - var expression = OrderMapper.MapOrderByField(item.OrderBy, filter); + var expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context); if (item.SortOrder == SortOrder.Ascending) { orderedQuery = orderedQuery!.ThenBy(expression); @@ -1356,7 +1631,7 @@ public sealed class BaseItemRepository if (maxWidth.HasValue) { - baseQuery = baseQuery.Where(e => e.Width >= maxWidth); + baseQuery = baseQuery.Where(e => e.Width <= maxWidth); } if (filter.MaxHeight.HasValue) @@ -1429,8 +1704,17 @@ public sealed class BaseItemRepository if (!string.IsNullOrEmpty(filter.SearchTerm)) { - var searchTerm = filter.SearchTerm.ToLower(); - baseQuery = baseQuery.Where(e => e.CleanName!.ToLower().Contains(searchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(searchTerm))); + var cleanedSearchTerm = GetCleanValue(filter.SearchTerm); + var originalSearchTerm = filter.SearchTerm.ToLower(); + if (SearchWildcardTerms.Any(f => cleanedSearchTerm.Contains(f))) + { + cleanedSearchTerm = $"%{cleanedSearchTerm.Trim('%')}%"; + baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!, cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle.ToLower(), originalSearchTerm))); + } + else + { + baseQuery = baseQuery.Where(e => e.CleanName!.Contains(cleanedSearchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(originalSearchTerm))); + } } if (filter.IsFolder.HasValue) @@ -1439,6 +1723,7 @@ public sealed class BaseItemRepository } var includeTypes = filter.IncludeItemTypes; + // Only specify excluded types if no included types are specified if (filter.IncludeItemTypes.Length == 0) { @@ -1464,25 +1749,10 @@ public sealed class BaseItemRepository baseQuery = baseQuery.Where(e => !excludeTypeName.Contains(e.Type)); } } - else if (includeTypes.Length == 1) - { - if (_itemTypeLookup.BaseItemKindNames.TryGetValue(includeTypes[0], out var includeTypeName)) - { - baseQuery = baseQuery.Where(e => e.Type == includeTypeName); - } - } - else if (includeTypes.Length > 1) + else { - var includeTypeName = new List<string>(); - foreach (var includeType in includeTypes) - { - if (_itemTypeLookup.BaseItemKindNames.TryGetValue(includeType, out var baseItemKindName)) - { - includeTypeName.Add(baseItemKindName!); - } - } - - baseQuery = baseQuery.Where(e => includeTypeName.Contains(e.Type)); + string[] types = includeTypes.Select(f => _itemTypeLookup.BaseItemKindNames.GetValueOrDefault(f)).Where(e => e != null).ToArray()!; + baseQuery = baseQuery.WhereOneOrMany(types, f => f.Type); } if (filter.ChannelIds.Count > 0) @@ -1497,7 +1767,8 @@ public sealed class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.Path)) { - baseQuery = baseQuery.Where(e => e.Path == filter.Path); + var pathToQuery = GetPathToSave(filter.Path); + baseQuery = baseQuery.Where(e => e.Path == pathToQuery); } if (!string.IsNullOrWhiteSpace(filter.PresentationUniqueKey)) @@ -1588,7 +1859,7 @@ public sealed class BaseItemRepository if (filter.MinPremiereDate.HasValue) { - baseQuery = baseQuery.Where(e => e.PremiereDate <= filter.MinPremiereDate.Value); + baseQuery = baseQuery.Where(e => e.PremiereDate >= filter.MinPremiereDate.Value); } if (filter.MaxPremiereDate.HasValue) @@ -1616,10 +1887,17 @@ public sealed class BaseItemRepository if (filter.PersonIds.Length > 0) { + var peopleEntityIds = context.BaseItems + .WhereOneOrMany(filter.PersonIds, b => b.Id) + .Join( + context.Peoples, + b => b.Name, + p => p.Name, + (b, p) => p.Id); + baseQuery = baseQuery - .Where(e => - context.PeopleBaseItemMap.Where(w => context.BaseItems.Where(r => filter.PersonIds.Contains(r.Id)).Any(f => f.Name == w.People.Name)) - .Any(f => f.ItemId == e.Id)); + .Where(e => context.PeopleBaseItemMap + .Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId))); } if (!string.IsNullOrWhiteSpace(filter.Person)) @@ -1655,26 +1933,35 @@ public sealed class BaseItemRepository var nameContains = filter.NameContains; if (!string.IsNullOrWhiteSpace(nameContains)) { - baseQuery = baseQuery.Where(e => - e.CleanName!.Contains(nameContains) - || e.OriginalTitle!.ToLower().Contains(nameContains!)); + if (SearchWildcardTerms.Any(f => nameContains.Contains(f))) + { + nameContains = $"%{nameContains.Trim('%')}%"; + baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName, nameContains) || EF.Functions.Like(e.OriginalTitle, nameContains)); + } + else + { + baseQuery = baseQuery.Where(e => + e.CleanName!.Contains(nameContains) + || e.OriginalTitle!.ToLower().Contains(nameContains!)); + } } if (!string.IsNullOrWhiteSpace(filter.NameStartsWith)) { - baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(filter.NameStartsWith) || e.Name!.StartsWith(filter.NameStartsWith)); + var startsWithLower = filter.NameStartsWith.ToLowerInvariant(); + baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(startsWithLower)); } if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater)) { - // i hate this - baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() > filter.NameStartsWithOrGreater[0] || e.Name!.FirstOrDefault() > filter.NameStartsWithOrGreater[0]); + var startsOrGreaterLower = filter.NameStartsWithOrGreater.ToLowerInvariant(); + baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(startsOrGreaterLower) >= 0); } if (!string.IsNullOrWhiteSpace(filter.NameLessThan)) { - // i hate this - baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() < filter.NameLessThan[0] || e.Name!.FirstOrDefault() < filter.NameLessThan[0]); + var lessThanLower = filter.NameLessThan.ToLowerInvariant(); + baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(lessThanLower ) < 0); } if (filter.ImageTypes.Length > 0) @@ -1706,7 +1993,7 @@ public sealed class BaseItemRepository // We should probably figure this out for all folders, but for right now, this is the only place where we need it if (filter.IncludeItemTypes.Length == 1 && filter.IncludeItemTypes[0] == BaseItemKind.Series) { - baseQuery = baseQuery.Where(e => context.BaseItems + baseQuery = baseQuery.Where(e => context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)) .Where(e => e.IsFolder == false && e.IsVirtualItem == false) .Where(f => f.UserData!.FirstOrDefault(e => e.UserId == filter.User!.Id && e.Played)!.Played) .Any(f => f.SeriesPresentationUniqueKey == e.PresentationUniqueKey) == filter.IsPlayed); @@ -1740,64 +2027,70 @@ public sealed class BaseItemRepository if (filter.ArtistIds.Length > 0) { - baseQuery = baseQuery - .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type <= ItemValueType.Artist && filter.ArtistIds.Contains(f.ItemId))); + baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds); } if (filter.AlbumArtistIds.Length > 0) { - baseQuery = baseQuery - .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Artist && filter.AlbumArtistIds.Contains(f.ItemId))); + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.AlbumArtist, filter.AlbumArtistIds); } if (filter.ContributingArtistIds.Length > 0) { - baseQuery = baseQuery - .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Artist && filter.ContributingArtistIds.Contains(f.ItemId))); + var contributingNames = context.BaseItems + .Where(b => filter.ContributingArtistIds.Contains(b.Id)) + .Select(b => b.CleanName); + + baseQuery = baseQuery.Where(e => + e.ItemValues!.Any(ivm => + ivm.ItemValue.Type == ItemValueType.Artist && + contributingNames.Contains(ivm.ItemValue.CleanValue)) + && + !e.ItemValues!.Any(ivm => + ivm.ItemValue.Type == ItemValueType.AlbumArtist && + contributingNames.Contains(ivm.ItemValue.CleanValue))); } if (filter.AlbumIds.Length > 0) { - baseQuery = baseQuery.Where(e => context.BaseItems.Where(f => filter.AlbumIds.Contains(f.Id)).Any(f => f.Name == e.Album)); + var subQuery = context.BaseItems.WhereOneOrMany(filter.AlbumIds, f => f.Id); + baseQuery = baseQuery.Where(e => subQuery.Any(f => f.Name == e.Album)); } if (filter.ExcludeArtistIds.Length > 0) { - baseQuery = baseQuery - .Where(e => !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Artist && filter.ExcludeArtistIds.Contains(f.ItemId))); + baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true); } if (filter.GenreIds.Count > 0) { - baseQuery = baseQuery - .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Genre && filter.GenreIds.Contains(f.ItemId))); + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds.ToArray()); } if (filter.Genres.Count > 0) { - var cleanGenres = filter.Genres.Select(e => GetCleanValue(e)).ToArray(); + var cleanGenres = filter.Genres.Select(e => GetCleanValue(e)).ToArray().OneOrManyExpressionBuilder<ItemValueMap, string>(f => f.ItemValue.CleanValue); baseQuery = baseQuery - .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Genre && cleanGenres.Contains(f.ItemValue.CleanValue))); + .Where(e => e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Genre).Any(cleanGenres)); } if (tags.Count > 0) { - var cleanValues = tags.Select(e => GetCleanValue(e)).ToArray(); + var cleanValues = tags.Select(e => GetCleanValue(e)).ToArray().OneOrManyExpressionBuilder<ItemValueMap, string>(f => f.ItemValue.CleanValue); baseQuery = baseQuery - .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && cleanValues.Contains(f.ItemValue.CleanValue))); + .Where(e => e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Tags).Any(cleanValues)); } if (excludeTags.Count > 0) { - var cleanValues = excludeTags.Select(e => GetCleanValue(e)).ToArray(); + var cleanValues = excludeTags.Select(e => GetCleanValue(e)).ToArray().OneOrManyExpressionBuilder<ItemValueMap, string>(f => f.ItemValue.CleanValue); baseQuery = baseQuery - .Where(e => !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && cleanValues.Contains(f.ItemValue.CleanValue))); + .Where(e => !e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Tags).Any(cleanValues)); } if (filter.StudioIds.Length > 0) { - baseQuery = baseQuery - .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Studios && filter.StudioIds.Contains(f.ItemId))); + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds.ToArray()); } if (filter.OfficialRatings.Length > 0) @@ -1810,22 +2103,26 @@ public sealed class BaseItemRepository if (filter.MinParentalRating != null) { var min = filter.MinParentalRating; - minParentalRatingFilter = e => e.InheritedParentalRatingValue >= min.Score || e.InheritedParentalRatingValue == null; - if (min.SubScore != null) - { - minParentalRatingFilter = minParentalRatingFilter.And(e => e.InheritedParentalRatingValue >= min.SubScore || e.InheritedParentalRatingValue == null); - } + var minScore = min.Score; + var minSubScore = min.SubScore ?? 0; + + minParentalRatingFilter = e => + e.InheritedParentalRatingValue == null || + e.InheritedParentalRatingValue > minScore || + (e.InheritedParentalRatingValue == minScore && (e.InheritedParentalRatingSubValue ?? 0) >= minSubScore); } Expression<Func<BaseItemEntity, bool>>? maxParentalRatingFilter = null; if (filter.MaxParentalRating != null) { var max = filter.MaxParentalRating; - maxParentalRatingFilter = e => e.InheritedParentalRatingValue <= max.Score || e.InheritedParentalRatingValue == null; - if (max.SubScore != null) - { - maxParentalRatingFilter = maxParentalRatingFilter.And(e => e.InheritedParentalRatingValue <= max.SubScore || e.InheritedParentalRatingValue == null); - } + var maxScore = max.Score; + var maxSubScore = max.SubScore ?? 0; + + maxParentalRatingFilter = e => + e.InheritedParentalRatingValue == null || + e.InheritedParentalRatingValue < maxScore || + (e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore); } if (filter.HasParentalRating ?? false) @@ -1961,7 +2258,7 @@ public sealed class BaseItemRepository if (filter.HasDeadParentId.HasValue && filter.HasDeadParentId.Value) { baseQuery = baseQuery - .Where(e => e.ParentId.HasValue && !context.BaseItems.Any(f => f.Id == e.ParentId.Value)); + .Where(e => e.ParentId.HasValue && !context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)).Any(f => f.Id == e.ParentId.Value)); } if (filter.IsDeadArtist.HasValue && filter.IsDeadArtist.Value) @@ -1988,15 +2285,9 @@ public sealed class BaseItemRepository .Where(e => !context.Peoples.Any(f => f.Name == e.Name)); } - if (filter.Years.Length == 1) - { - baseQuery = baseQuery - .Where(e => e.ProductionYear == filter.Years[0]); - } - else if (filter.Years.Length > 1) + if (filter.Years.Length > 0) { - baseQuery = baseQuery - .Where(e => filter.Years.Any(f => f == e.ProductionYear)); + baseQuery = baseQuery.WhereOneOrMany(filter.Years, e => e.ProductionYear!.Value); } var isVirtualItem = filter.IsVirtualItem ?? filter.IsMissing; @@ -2037,45 +2328,61 @@ public sealed class BaseItemRepository if (filter.MediaTypes.Length > 0) { var mediaTypes = filter.MediaTypes.Select(f => f.ToString()).ToArray(); - baseQuery = baseQuery - .Where(e => mediaTypes.Contains(e.MediaType)); + baseQuery = baseQuery.WhereOneOrMany(mediaTypes, e => e.MediaType); } if (filter.ItemIds.Length > 0) { - baseQuery = baseQuery - .Where(e => filter.ItemIds.Contains(e.Id)); + baseQuery = baseQuery.WhereOneOrMany(filter.ItemIds, e => e.Id); } if (filter.ExcludeItemIds.Length > 0) { baseQuery = baseQuery - .Where(e => !filter.ItemIds.Contains(e.Id)); + .Where(e => !filter.ExcludeItemIds.Contains(e.Id)); } if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0) { - baseQuery = baseQuery.Where(e => !e.Provider!.All(f => !filter.ExcludeProviderIds.All(w => f.ProviderId == w.Key && f.ProviderValue == w.Value))); + var exclude = filter.ExcludeProviderIds.Select(e => $"{e.Key}:{e.Value}").ToArray(); + baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.All(f => !exclude.Contains(f))); } if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0) { - baseQuery = baseQuery.Where(e => e.Provider!.Any(f => !filter.HasAnyProviderId.Any(w => f.ProviderId == w.Key && f.ProviderValue == w.Value))); + // Allow setting a null or empty value to get all items that have the specified provider set. + var includeAny = filter.HasAnyProviderId.Where(e => string.IsNullOrEmpty(e.Value)).Select(e => e.Key).ToArray(); + if (includeAny.Length > 0) + { + baseQuery = baseQuery.Where(e => e.Provider!.Any(f => includeAny.Contains(f.ProviderId))); + } + + var includeSelected = filter.HasAnyProviderId.Where(e => !string.IsNullOrEmpty(e.Value)).Select(e => $"{e.Key}:{e.Value}").ToArray(); + if (includeSelected.Length > 0) + { + baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeSelected.Contains(f))); + } } if (filter.HasImdbId.HasValue) { - baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "imdb")); + baseQuery = filter.HasImdbId.Value + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Imdb.ToString().ToLower())) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Imdb.ToString().ToLower())); } if (filter.HasTmdbId.HasValue) { - baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tmdb")); + baseQuery = filter.HasTmdbId.Value + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tmdb.ToString().ToLower())) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tmdb.ToString().ToLower())); } if (filter.HasTvdbId.HasValue) { - baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tvdb")); + baseQuery = filter.HasTvdbId.Value + ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tvdb.ToString().ToLower())) + : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tvdb.ToString().ToLower())); } var queryTopParentIds = filter.TopParentIds; @@ -2090,19 +2397,19 @@ public sealed class BaseItemRepository } else { - baseQuery = baseQuery.Where(e => queryTopParentIds.Contains(e.TopParentId!.Value)); + baseQuery = baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value); } } if (filter.AncestorIds.Length > 0) { - baseQuery = baseQuery.Where(e => e.Children!.Any(f => filter.AncestorIds.Contains(f.ParentItemId))); + baseQuery = baseQuery.Where(e => e.Parents!.Any(f => filter.AncestorIds.Contains(f.ParentItemId))); } if (!string.IsNullOrWhiteSpace(filter.AncestorWithPresentationUniqueKey)) { baseQuery = baseQuery - .Where(e => context.BaseItems.Where(f => f.PresentationUniqueKey == filter.AncestorWithPresentationUniqueKey).Any(f => f.Children!.Any(w => w.ItemId == e.Id))); + .Where(e => context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)).Where(f => f.PresentationUniqueKey == filter.AncestorWithPresentationUniqueKey).Any(f => f.Children!.Any(w => w.ItemId == e.Id))); } if (!string.IsNullOrWhiteSpace(filter.SeriesPresentationUniqueKey)) @@ -2113,40 +2420,34 @@ public sealed class BaseItemRepository if (filter.ExcludeInheritedTags.Length > 0) { - baseQuery = baseQuery - .Where(e => !e.ItemValues!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags) - .Any(f => filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue))); + baseQuery = baseQuery.Where(e => + !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue)) + && (e.Type != _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode] || !e.SeriesId.HasValue || + !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue)))); } if (filter.IncludeInheritedTags.Length > 0) { - // Episodes do not store inherit tags from their parents in the database, and the tag may be still required by the client. - // In addition to the tags for the episodes themselves, we need to manually query its parent (the season)'s tags as well. - if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Episode) + // For seasons and episodes, we also need to check the parent series' tags. + if (includeTypes.Any(t => t == BaseItemKind.Episode || t == BaseItemKind.Season)) { - baseQuery = baseQuery - .Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags) - .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)) - || - (e.ParentId.HasValue && context.ItemValuesMap.Where(w => w.ItemId == e.ParentId.Value)!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags) - .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)))); + baseQuery = baseQuery.Where(e => + e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)) + || (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)))); } // A playlist should be accessible to its owner regardless of allowed tags. else if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Playlist) { - baseQuery = baseQuery - .Where(e => - e.Parents! - .Any(f => - f.ParentItem.ItemValues!.Any(w => w.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(w.ItemValue.CleanValue)) - || e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""))); + baseQuery = baseQuery.Where(e => + e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)) + || e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")); // d ^^ this is stupid it hate this. } else { - baseQuery = baseQuery - .Where(e => e.Parents!.Any(f => f.ParentItem.ItemValues!.Any(w => w.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(w.ItemValue.CleanValue)))); + baseQuery = baseQuery.Where(e => + e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))); } } @@ -2229,4 +2530,78 @@ public sealed class BaseItemRepository return baseQuery; } + + /// <inheritdoc/> + public async Task<bool> ItemExistsAsync(Guid id) + { + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + return await dbContext.BaseItems.AnyAsync(f => f.Id == id).ConfigureAwait(false); + } + } + + /// <inheritdoc/> + public bool GetIsPlayed(User user, Guid id, bool recursive) + { + using var dbContext = _dbProvider.CreateDbContext(); + + if (recursive) + { + var folderList = TraverseHirachyDown(id, dbContext, item => (item.IsFolder || item.IsVirtualItem)); + + return dbContext.BaseItems + .Where(e => folderList.Contains(e.ParentId!.Value) && !e.IsFolder && !e.IsVirtualItem) + .All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played)); + } + + return dbContext.BaseItems.Where(e => e.ParentId == id).All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played)); + } + + private static HashSet<Guid> TraverseHirachyDown(Guid parentId, JellyfinDbContext dbContext, Expression<Func<BaseItemEntity, bool>>? filter = null) + { + var folderStack = new HashSet<Guid>() + { + parentId + }; + var folderList = new HashSet<Guid>() + { + parentId + }; + + while (folderStack.Count != 0) + { + var items = folderStack.ToArray(); + folderStack.Clear(); + var query = dbContext.BaseItems + .WhereOneOrMany(items, e => e.ParentId!.Value); + + if (filter != null) + { + query = query.Where(filter); + } + + foreach (var item in query.Select(e => e.Id).ToArray()) + { + if (folderList.Add(item)) + { + folderStack.Add(item); + } + } + } + + return folderList; + } + + /// <inheritdoc/> + public IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames) + { + using var dbContext = _dbProvider.CreateDbContext(); + + var artists = dbContext.BaseItems.Where(e => e.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]!) + .Where(e => artistNames.Contains(e.Name)) + .ToArray(); + + return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Cast<MusicArtist>().ToArray()); + } } |
