diff options
Diffstat (limited to 'src/Jellyfin.Database/Jellyfin.Database.Implementations')
8 files changed, 226 insertions, 28 deletions
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index 43e6a8bc00..88a2c684ff 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -111,7 +111,9 @@ public static class DescendantQueryHelper private static HashSet<Guid> GetMatchingMediaStreamItemIds(JellyfinDbContext context, HasMediaStreamType criteria) { var query = context.MediaStreamInfos - .Where(ms => ms.StreamType == criteria.StreamType && ms.Language == criteria.Language); + .Where(ms => ms.StreamType == criteria.StreamType + && (criteria.Language.Contains(ms.Language) + || (criteria.Language.Contains("und") && string.IsNullOrEmpty(ms.Language)))); // und = undetermined if (criteria.IsExternal.HasValue) { diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs index 6c81fa729c..b10e210e5d 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs @@ -27,6 +27,7 @@ namespace Jellyfin.Database.Implementations.Entities ArgumentException.ThrowIfNullOrEmpty(passwordResetProviderId); Username = username; + NormalizedUsername = username.ToUpperInvariant(); AuthenticationProviderId = authenticationProviderId; PasswordResetProviderId = passwordResetProviderId; @@ -74,6 +75,16 @@ namespace Jellyfin.Database.Implementations.Entities public string Username { get; set; } /// <summary> + /// Gets or sets the user's normalized name. + /// </summary> + /// <remarks> + /// Required, Max length = 255. + /// </remarks> + [MaxLength(255)] + [StringLength(255)] + public string NormalizedUsername { get; set; } + + /// <summary> /// Gets or sets the user's password, or <c>null</c> if none is set. /// </summary> /// <remarks> diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs index b2bcbf2bb6..34810b9199 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs @@ -108,5 +108,50 @@ public enum ViewType /// <summary> /// Shows upcoming. /// </summary> - Upcoming = 20 + Upcoming = 20, + + /// <summary> + /// Shows authors. + /// </summary> + Authors = 21, + + /// <summary> + /// Shows books. + /// </summary> + Books = 22, + + /// <summary> + /// Shows folders. + /// </summary> + Folders = 23, + + /// <summary> + /// Shows mixed media. + /// </summary> + Mixed = 24, + + /// <summary> + /// Shows photos. + /// </summary> + Photos = 25, + + /// <summary> + /// Shows photo albums. + /// </summary> + PhotoAlbums = 26, + + /// <summary> + /// Shows series timers. + /// </summary> + SeriesTimers = 27, + + /// <summary> + /// Shows studios. + /// </summary> + Studios = 28, + + /// <summary> + /// Shows videos. + /// </summary> + Videos = 29 } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs index f6fce7279a..b0c12bf592 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs @@ -273,6 +273,11 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog }).ConfigureAwait(false); return result; } + catch (DbUpdateConcurrencyException) + { + // a concurrency exception is supposed to be always handled by the invoker of the method, logging it here is only causing log bloat. + throw; + } catch (Exception e) { logger.LogError(e, "Error trying to save changes."); @@ -294,6 +299,11 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog }); return result; } + catch (DbUpdateConcurrencyException) + { + // a concurrency exception is supposed to be always handled by the invoker of the method, logging it here is only causing log bloat. + throw; + } catch (Exception e) { logger.LogError(e, "Error trying to save changes."); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs index f386e882e2..fec37ce723 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs @@ -70,15 +70,24 @@ public static class JellyfinQueryHelperExtensions bool invert = false) { var itemFilter = OneOrManyExpressionBuilder<BaseItemEntity, Guid>(referenceIds, f => f.Id); - var typeFilter = OneOrManyExpressionBuilder<ItemValue, ItemValueType>(itemValueTypes, iv => iv.Type); - - return baseQuery.Where(item => - context.ItemValues - .Where(typeFilter) - .Join(context.ItemValuesMap, e => e.ItemValueId, e => e.ItemValueId, (itemVal, map) => new { itemVal, map }) - .Any(val => - context.BaseItems.Where(itemFilter).Any(e => e.CleanName == val.itemVal.CleanValue) - && val.map.ItemId == item.Id) == EF.Constant(!invert)); + var typeFilter = OneOrManyExpressionBuilder<ItemValueMap, ItemValueType>(itemValueTypes, m => m.ItemValue.Type); + + // Flat sub-selects + Contains instead of a nested correlated .Any(...Any(...)). + var referencedCleanValues = context.BaseItems + .Where(itemFilter) + .Select(e => e.CleanName); + + var matchingItemIds = context.ItemValuesMap + .Where(typeFilter) + .Where(m => referencedCleanValues.Contains(m.ItemValue.CleanValue)) + .Select(m => m.ItemId); + + if (invert) + { + return baseQuery.Where(e => !matchingItemIds.Contains(e.Id)); + } + + return baseQuery.Where(e => matchingItemIds.Contains(e.Id)); } /// <summary> @@ -102,13 +111,107 @@ public static class JellyfinQueryHelperExtensions var itemFilter = OneOrManyExpressionBuilder<BaseItemEntity, Guid>(referenceIds, f => f.Id); - return item => - context.ItemValues - .Join(context.ItemValuesMap, e => e.ItemValueId, e => e.ItemValueId, (item, map) => new { item, map }) - .Any(val => - val.item.Type == itemValueType - && context.BaseItems.Where(itemFilter).Any(e => e.CleanName == val.item.CleanValue) - && val.map.ItemId == item.Id) == EF.Constant(!invert); + // Flat sub-selects + Contains instead of a nested correlated .Any(...Any(...)). + var referencedCleanValues = context.BaseItems + .Where(itemFilter) + .Select(e => e.CleanName); + + var matchingItemIds = context.ItemValuesMap + .Where(m => m.ItemValue.Type == itemValueType && referencedCleanValues.Contains(m.ItemValue.CleanValue)) + .Select(m => m.ItemId); + + if (invert) + { + return item => !matchingItemIds.Contains(item.Id); + } + + return item => matchingItemIds.Contains(item.Id); + } + + /// <summary> + /// Filters items that match any of the specified (provider name, value) pairs. + /// </summary> + /// <param name="baseQuery">The source query.</param> + /// <param name="providerIds">Dictionary mapping provider names to arrays of values to match.</param> + /// <returns>A filtered query.</returns> + public static IQueryable<BaseItemEntity> WhereHasAnyProviderIds( + this IQueryable<BaseItemEntity> baseQuery, + IReadOnlyDictionary<string, string[]> providerIds) + { + var providerKeys = providerIds + .SelectMany(kvp => kvp.Value.Select(v => $"{kvp.Key}:{v}")) + .ToList(); + + if (providerKeys.Count == 0) + { + return baseQuery; + } + + return baseQuery.Where(e => e.Provider!.Any(p => providerKeys.Contains(p.ProviderId + ":" + p.ProviderValue))); + } + + /// <summary> + /// Filters items that have any of the specified providers. Empty/null values match any value for that provider. + /// </summary> + /// <param name="baseQuery">The source query.</param> + /// <param name="providerIds">Dictionary mapping provider names to optional values.</param> + /// <returns>A filtered query.</returns> + public static IQueryable<BaseItemEntity> WhereHasAnyProviderId( + this IQueryable<BaseItemEntity> baseQuery, + IReadOnlyDictionary<string, string> providerIds) + { + var existenceOnly = providerIds + .Where(e => string.IsNullOrEmpty(e.Value)) + .Select(e => e.Key) + .ToList(); + + var specificValues = providerIds + .Where(e => !string.IsNullOrEmpty(e.Value)) + .Select(e => $"{e.Key}:{e.Value}") + .ToList(); + + if (existenceOnly.Count == 0 && specificValues.Count == 0) + { + return baseQuery; + } + + if (existenceOnly.Count == 0) + { + return baseQuery.Where(e => e.Provider!.Any(p => + specificValues.Contains(p.ProviderId + ":" + p.ProviderValue))); + } + + if (specificValues.Count == 0) + { + return baseQuery.Where(e => e.Provider!.Any(p => existenceOnly.Contains(p.ProviderId))); + } + + // Single EXISTS over Provider with both predicates OR'd, instead of two separate subqueries. + return baseQuery.Where(e => e.Provider!.Any(p => + existenceOnly.Contains(p.ProviderId) || + specificValues.Contains(p.ProviderId + ":" + p.ProviderValue))); + } + + /// <summary> + /// Excludes items that match any of the specified (provider name, value) pairs. + /// </summary> + /// <param name="baseQuery">The source query.</param> + /// <param name="providerIds">Dictionary mapping provider names to values to exclude.</param> + /// <returns>A filtered query.</returns> + public static IQueryable<BaseItemEntity> WhereExcludeProviderIds( + this IQueryable<BaseItemEntity> baseQuery, + IReadOnlyDictionary<string, string> providerIds) + { + var excludeKeys = providerIds + .Select(e => $"{e.Key}:{e.Value}") + .ToList(); + + if (excludeKeys.Count == 0) + { + return baseQuery; + } + + return baseQuery.Where(e => e.Provider!.All(p => !excludeKeys.Contains(p.ProviderId + ":" + p.ProviderValue))); } /// <summary> @@ -138,13 +241,14 @@ public static class JellyfinQueryHelperExtensions var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key)); - if (oneOf.Count < 4) // arbitrary value choosen. - { - // if we have 3 or fewer values to check against its faster to do a IN(const,const,const) lookup - return Expression.Lambda<Func<TEntity, bool>>(Expression.Call(null, containsMethodInfo, Expression.Constant(oneOf), property.Body), parameter); - } - - return Expression.Lambda<Func<TEntity, bool>>(Expression.Call(null, containsMethodInfo, Expression.Call(null, _efParameterInstruction.MakeGenericMethod(oneOf.GetType()), Expression.Constant(oneOf)), property.Body), parameter); + // Always wrap the collection in EF.Parameter so EF Core caches a single compiled plan and reuses it across calls. + return Expression.Lambda<Func<TEntity, bool>>( + Expression.Call( + null, + containsMethodInfo, + Expression.Call(null, _efParameterInstruction.MakeGenericMethod(oneOf.GetType()), Expression.Constant(oneOf)), + property.Body), + parameter); } internal static class ParameterReplacer diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/MatchCriteria/HasMediaStreamType.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/MatchCriteria/HasMediaStreamType.cs index 68f2ca2786..c1f6ab16a9 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/MatchCriteria/HasMediaStreamType.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/MatchCriteria/HasMediaStreamType.cs @@ -1,3 +1,6 @@ +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + +using System.Collections.Generic; using Jellyfin.Database.Implementations.Entities; namespace Jellyfin.Database.Implementations.MatchCriteria; @@ -6,9 +9,23 @@ namespace Jellyfin.Database.Implementations.MatchCriteria; /// Matches folders containing descendants with a specific media stream type and language. /// </summary> /// <param name="StreamType">The type of media stream to match (Audio, Subtitle, etc.).</param> -/// <param name="Language">The language to match.</param> +/// <param name="Language">List of languages to match.</param> /// <param name="IsExternal">If not null, filters by internal (false) or external (true) streams. Only applicable to subtitles.</param> public sealed record HasMediaStreamType( MediaStreamTypeEntity StreamType, - string Language, - bool? IsExternal = null) : FolderMatchCriteria; + IReadOnlyCollection<string> Language, + bool? IsExternal = null) : FolderMatchCriteria +{ + /// <summary> + /// Initializes a new instance of the <see cref="HasMediaStreamType"/> class. + /// </summary> + /// <param name="StreamType">The type of media stream to match (Audio, Subtitle, etc.).</param> + /// <param name="Language">The language to match.</param> + /// <param name="IsExternal">If not null, filters by internal (false) or external (true) streams. Only applicable to subtitles.</param> + public HasMediaStreamType( + MediaStreamTypeEntity StreamType, + string Language, + bool? IsExternal = null) : this(StreamType, [Language], IsExternal) + { + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs index 8556fb7bb3..ee36be035b 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs @@ -61,6 +61,11 @@ public class BaseItemConfiguration : IEntityTypeConfiguration<BaseItemEntity> builder.HasIndex(e => new { e.TopParentId, e.MediaType, e.IsVirtualItem, e.DateCreated }); // resume builder.HasIndex(e => new { e.MediaType, e.TopParentId, e.IsVirtualItem, e.PresentationUniqueKey }); + // alternate versions of an item, e.g. resolving the played date of a version onto its primary. + // Filtered: almost no item has a primary version, and an index covering those rows too would tempt + // the planner into serving "PrimaryVersionId IS NULL" - true for the whole library - out of it. + builder.HasIndex(e => e.PrimaryVersionId) + .HasFilter("\"PrimaryVersionId\" IS NOT NULL"); // sorted library queries (e.g., Series sorted by SortName) builder.HasIndex(e => new { e.Type, e.TopParentId, e.SortName }); // NextUp: per-series episode ordering (index seek + range scan on season/episode) diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserConfiguration.cs index 61b5e06e8a..ed4138680d 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserConfiguration.cs @@ -50,6 +50,10 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration builder .HasIndex(entity => entity.Username) .IsUnique(); + + builder + .HasIndex(entity => entity.NormalizedUsername) + .IsUnique(); } } } |
