aboutsummaryrefslogtreecommitdiff
path: root/src/Jellyfin.Database/Jellyfin.Database.Implementations
diff options
context:
space:
mode:
Diffstat (limited to 'src/Jellyfin.Database/Jellyfin.Database.Implementations')
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs9
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/LinkedChildEntity.cs2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs14
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/ViewType.cs47
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj1
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs245
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs4
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs10
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs5
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/LinkedChildConfiguration.cs3
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs1
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserConfiguration.cs4
16 files changed, 272 insertions, 81 deletions
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs
index 88a2c684ff..bfd0fac34a 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Linq.Expressions;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.MatchCriteria;
@@ -13,6 +14,14 @@ namespace Jellyfin.Database.Implementations;
public static class DescendantQueryHelper
{
/// <summary>
+ /// Gets the predicate identifying items that count toward played/total aggregation:
+ /// real leaf media, i.e. neither folders nor virtual items (missing or unaired episodes).
+ /// Shared by the per-item and batched count paths so they cannot diverge.
+ /// </summary>
+ public static Expression<Func<BaseItemEntity, bool>> IsCountableLeaf { get; } =
+ b => !b.IsFolder && !b.IsVirtualItem;
+
+ /// <summary>
/// Gets a queryable of all descendant IDs for a parent item.
/// Traverses AncestorIds and LinkedChildren to find all descendants.
/// </summary>
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/LinkedChildEntity.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/LinkedChildEntity.cs
index 7361775711..be315f1b2c 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/LinkedChildEntity.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/LinkedChildEntity.cs
@@ -25,7 +25,7 @@ public class LinkedChildEntity
/// <summary>
/// Gets or sets the sort order.
/// </summary>
- public int? SortOrder { get; set; }
+ public int SortOrder { get; set; }
/// <summary>
/// Gets or sets the parent item navigation property.
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs
index 84b86574cc..eae02dda1c 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs
@@ -37,7 +37,7 @@ namespace Jellyfin.Database.Implementations.Entities
/// <summary>
/// Gets or sets the id of the associated user.
/// </summary>
- public Guid? UserId { get; set; }
+ public Guid UserId { get; set; }
/// <summary>
/// Gets the type of this permission.
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs
index c02ea7375a..9bd159f2cf 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs
@@ -35,7 +35,7 @@ namespace Jellyfin.Database.Implementations.Entities
/// <summary>
/// Gets or sets the id of the associated user.
/// </summary>
- public Guid? UserId { get; set; }
+ public Guid UserId { get; set; }
/// <summary>
/// Gets the type of this preference.
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
index 6c81fa729c..bf6568d10a 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
-using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Database.Implementations.Interfaces;
@@ -27,6 +26,7 @@ namespace Jellyfin.Database.Implementations.Entities
ArgumentException.ThrowIfNullOrEmpty(passwordResetProviderId);
Username = username;
+ NormalizedUsername = username.ToUpperInvariant();
AuthenticationProviderId = authenticationProviderId;
PasswordResetProviderId = passwordResetProviderId;
@@ -74,6 +74,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>
@@ -315,7 +325,6 @@ namespace Jellyfin.Database.Implementations.Entities
/// <summary>
/// Gets the list of permissions this user has.
/// </summary>
- [ForeignKey("Permission_Permissions_Guid")]
public virtual ICollection<Permission> Permissions { get; private set; }
/*
@@ -328,7 +337,6 @@ namespace Jellyfin.Database.Implementations.Entities
/// <summary>
/// Gets the list of preferences this user has.
/// </summary>
- [ForeignKey("Preference_Preferences_Guid")]
public virtual ICollection<Preference> Preferences { get; private set; }
/// <inheritdoc/>
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/Jellyfin.Database.Implementations.csproj b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj
index 0b29a71cbd..887ba114fc 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj
@@ -13,7 +13,6 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Database.Implementations</PackageId>
- <VersionPrefix>10.11.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs
index f386e882e2..0dfce732ce 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs
@@ -14,11 +14,17 @@ namespace Jellyfin.Database.Implementations;
/// <summary>
/// Contains a number of query related extensions.
/// </summary>
+/// <remarks>
+/// Every helper here binds its values through <see cref="EF.Parameter{T}(T)"/>. Values embedded as bare
+/// constants are inlined into the SQL as literals, which gives each distinct value its own entry in EF's
+/// compiled query cache and its own statement for the database to plan.
+/// </remarks>
public static class JellyfinQueryHelperExtensions
{
private static readonly MethodInfo _containsMethodGenericCache = typeof(Enumerable).GetMethods(BindingFlags.Public | BindingFlags.Static).First(m => m.Name == nameof(Enumerable.Contains) && m.GetParameters().Length == 2);
private static readonly MethodInfo _efParameterInstruction = typeof(EF).GetMethod(nameof(EF.Parameter), BindingFlags.Public | BindingFlags.Static)!;
private static readonly ConcurrentDictionary<Type, MethodInfo> _containsQueryCache = new();
+ private static readonly ConcurrentDictionary<Type, MethodInfo> _efParameterCache = new();
/// <summary>
/// Builds an optimised query checking one property against a list of values while maintaining an optimal query.
@@ -26,15 +32,69 @@ public static class JellyfinQueryHelperExtensions
/// <typeparam name="TEntity">The entity.</typeparam>
/// <typeparam name="TProperty">The property type to compare.</typeparam>
/// <param name="query">The source query.</param>
- /// <param name="oneOf">The list of items to check.</param>
+ /// <param name="oneOf">The list of items to check. An empty list matches nothing.</param>
/// <param name="property">Property expression.</param>
/// <returns>A Query.</returns>
- public static IQueryable<TEntity> WhereOneOrMany<TEntity, TProperty>(this IQueryable<TEntity> query, IList<TProperty> oneOf, Expression<Func<TEntity, TProperty>> property)
+ public static IQueryable<TEntity> WhereOneOrMany<TEntity, TProperty>(this IQueryable<TEntity> query, IReadOnlyList<TProperty> oneOf, Expression<Func<TEntity, TProperty>> property)
{
return query.Where(OneOrManyExpressionBuilder(oneOf, property));
}
/// <summary>
+ /// Builds an optimised query expression checking one property against a list of values while maintaining an optimal query.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity.</typeparam>
+ /// <typeparam name="TProperty">The property type to compare.</typeparam>
+ /// <param name="oneOf">The list of items to check. An empty list matches nothing.</param>
+ /// <param name="property">Property expression.</param>
+ /// <returns>A Query.</returns>
+ public static Expression<Func<TEntity, bool>> OneOrManyExpressionBuilder<TEntity, TProperty>(this IReadOnlyList<TProperty> oneOf, Expression<Func<TEntity, TProperty>> property)
+ {
+ ArgumentNullException.ThrowIfNull(oneOf);
+ ArgumentNullException.ThrowIfNull(property);
+
+ var parameter = Expression.Parameter(typeof(TEntity), "item");
+ property = ParameterReplacer.Replace<Func<TEntity, TProperty>, Func<TEntity, TProperty>>(property, property.Parameters[0], parameter);
+
+ if (oneOf.Count == 0)
+ {
+ // Fail closed, and without asking the database to unpack an empty collection to prove it.
+ return Expression.Lambda<Func<TEntity, bool>>(Expression.Constant(false), parameter);
+ }
+
+ if (oneOf.Count == 1)
+ {
+ var value = Expression.Call(
+ null,
+ EfParameterFor(typeof(TProperty)),
+ Expression.Constant(oneOf[0], typeof(TProperty)));
+
+ return Expression.Lambda<Func<TEntity, bool>>(
+ typeof(TProperty).IsValueType
+ ? Expression.Equal(property.Body, value)
+ : Expression.ReferenceEqual(property.Body, value),
+ parameter);
+ }
+
+ var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key));
+
+ // Binding the whole collection as one parameter keeps the statement identical for any element
+ // count, instead of emitting one placeholder per element.
+ return Expression.Lambda<Func<TEntity, bool>>(
+ Expression.Call(
+ null,
+ containsMethodInfo,
+ Expression.Call(null, EfParameterFor(oneOf.GetType()), Expression.Constant(oneOf)),
+ property.Body),
+ parameter);
+ }
+
+ private static MethodInfo EfParameterFor(Type type)
+ {
+ return _efParameterCache.GetOrAdd(type, static (key) => _efParameterInstruction.MakeGenericMethod(key));
+ }
+
+ /// <summary>
/// Builds a query that checks referenced ItemValues for a cross BaseItem lookup.
/// </summary>
/// <param name="baseQuery">The source query.</param>
@@ -47,104 +107,161 @@ public static class JellyfinQueryHelperExtensions
this IQueryable<BaseItemEntity> baseQuery,
JellyfinDbContext context,
ItemValueType itemValueType,
- IList<Guid> referenceIds,
+ IReadOnlyList<Guid> referenceIds,
bool invert = false)
{
- return baseQuery.Where(ReferencedItemFilterExpressionBuilder(context, itemValueType, referenceIds, invert));
+ return baseQuery.WhereReferencedItem(context, [itemValueType], referenceIds, invert);
}
/// <summary>
- /// Builds a query that checks referenced ItemValues for a cross BaseItem lookup.
+ /// Builds a query that checks referenced ItemValues of any of the given types for a cross BaseItem lookup.
/// </summary>
/// <param name="baseQuery">The source query.</param>
/// <param name="context">The database context.</param>
- /// <param name="itemValueTypes">The type of item value to reference.</param>
+ /// <param name="itemValueTypes">The types of item value to reference.</param>
/// <param name="referenceIds">The list of BaseItem ids to check matches.</param>
/// <param name="invert">If set an exclusion check is performed instead.</param>
/// <returns>A Query.</returns>
- public static IQueryable<BaseItemEntity> WhereReferencedItemMultipleTypes(
+ /// <remarks>
+ /// Matching is on CleanName alone. Genre/artist/album etc items do not set an ItemValue of their own
+ /// type, so the referenced item's Type is never consulted and ids whose names clean to the same value
+ /// are interchangeable across types.
+ /// </remarks>
+ public static IQueryable<BaseItemEntity> WhereReferencedItem(
this IQueryable<BaseItemEntity> baseQuery,
JellyfinDbContext context,
- IList<ItemValueType> itemValueTypes,
- IList<Guid> referenceIds,
+ IReadOnlyList<ItemValueType> itemValueTypes,
+ IReadOnlyList<Guid> referenceIds,
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));
+ ArgumentNullException.ThrowIfNull(context);
+
+ // Flat sub-selects rather than a correlated .Any(...Any(...)).
+ var referencedCleanValues = context.BaseItems
+ .Where(OneOrManyExpressionBuilder<BaseItemEntity, Guid>(referenceIds, e => e.Id))
+ .Select(e => e.CleanName);
+
+ var matchingItemIds = context.ItemValuesMap
+ .Where(OneOrManyExpressionBuilder<ItemValueMap, ItemValueType>(itemValueTypes, m => m.ItemValue.Type))
+ .Where(m => referencedCleanValues.Contains(m.ItemValue.CleanValue))
+ .Select(m => m.ItemId);
+
+ return invert
+ ? baseQuery.Where(e => !matchingItemIds.Contains(e.Id))
+ : baseQuery.Where(e => matchingItemIds.Contains(e.Id));
}
/// <summary>
- /// Builds a query expression that checks referenced ItemValues for a cross BaseItem lookup.
+ /// Filters items that have any of the specified providers, optionally restricted to given values.
/// </summary>
- /// <param name="context">The database context.</param>
- /// <param name="itemValueType">The type of item value to reference.</param>
- /// <param name="referenceIds">The list of BaseItem ids to check matches.</param>
- /// <param name="invert">If set an exclusion check is performed instead.</param>
- /// <returns>A Query.</returns>
- public static Expression<Func<BaseItemEntity, bool>> ReferencedItemFilterExpressionBuilder(
- this JellyfinDbContext context,
- ItemValueType itemValueType,
- IList<Guid> referenceIds,
- bool invert = false)
+ /// <param name="baseQuery">The source query.</param>
+ /// <param name="providerIds">Dictionary mapping provider names to values to match. An empty value array matches any value for that provider.</param>
+ /// <returns>A filtered query.</returns>
+ public static IQueryable<BaseItemEntity> WhereHasAnyProviderIds(
+ this IQueryable<BaseItemEntity> baseQuery,
+ IReadOnlyDictionary<string, string[]> providerIds)
{
- // Well genre/artist/album etc items do not actually set the ItemValue of thier specitic types so we cannot match it that way.
- /*
- "(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=@GenreIds and Type=2)))"
- */
-
- 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);
+ return baseQuery.WhereProviderMatch(Flatten(providerIds), false);
}
/// <summary>
- /// Builds an optimised query expression checking one property against a list of values while maintaining an optimal query.
+ /// Filters items that have any of the specified providers, optionally restricted to a given value.
/// </summary>
- /// <typeparam name="TEntity">The entity.</typeparam>
- /// <typeparam name="TProperty">The property type to compare.</typeparam>
- /// <param name="oneOf">The list of items to check.</param>
- /// <param name="property">Property expression.</param>
- /// <returns>A Query.</returns>
- public static Expression<Func<TEntity, bool>> OneOrManyExpressionBuilder<TEntity, TProperty>(this IList<TProperty> oneOf, Expression<Func<TEntity, TProperty>> property)
+ /// <param name="baseQuery">The source query.</param>
+ /// <param name="providerIds">Dictionary mapping provider names to optional values. An empty value matches any value for that provider.</param>
+ /// <returns>A filtered query.</returns>
+ public static IQueryable<BaseItemEntity> WhereHasAnyProviderId(
+ this IQueryable<BaseItemEntity> baseQuery,
+ IReadOnlyDictionary<string, string> providerIds)
{
- var parameter = Expression.Parameter(typeof(TEntity), "item");
- property = ParameterReplacer.Replace<Func<TEntity, TProperty>, Func<TEntity, TProperty>>(property, property.Parameters[0], parameter);
- if (oneOf.Count == 1)
+ return baseQuery.WhereProviderMatch(providerIds, false);
+ }
+
+ /// <summary>
+ /// Excludes items that have any of the specified providers, optionally restricted to a given value.
+ /// </summary>
+ /// <param name="baseQuery">The source query.</param>
+ /// <param name="providerIds">Dictionary mapping provider names to optional values. An empty value excludes any value for that provider.</param>
+ /// <returns>A filtered query.</returns>
+ public static IQueryable<BaseItemEntity> WhereExcludeProviderIds(
+ this IQueryable<BaseItemEntity> baseQuery,
+ IReadOnlyDictionary<string, string> providerIds)
+ {
+ return baseQuery.WhereProviderMatch(providerIds, true);
+ }
+
+ private static IEnumerable<KeyValuePair<string, string>> Flatten(IReadOnlyDictionary<string, string[]> providerIds)
+ {
+ ArgumentNullException.ThrowIfNull(providerIds);
+
+ foreach (var (provider, values) in providerIds)
{
- var value = oneOf[0];
- if (typeof(TProperty).IsValueType)
+ if (values is null || values.Length == 0)
{
- return Expression.Lambda<Func<TEntity, bool>>(Expression.Equal(property.Body, Expression.Constant(value)), parameter);
+ yield return new KeyValuePair<string, string>(provider, string.Empty);
+ continue;
}
- else
+
+ foreach (var value in values)
{
- return Expression.Lambda<Func<TEntity, bool>>(Expression.ReferenceEqual(property.Body, Expression.Constant(value)), parameter);
+ yield return new KeyValuePair<string, string>(provider, value);
}
}
+ }
- var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key));
+ /// <summary>
+ /// Matches items against a set of (provider, value) pairs, where an empty value means any value for
+ /// that provider. Emits a single EXISTS over the provider collection with the predicates OR'd, rather
+ /// than one subquery per predicate group.
+ /// </summary>
+ private static IQueryable<BaseItemEntity> WhereProviderMatch(
+ this IQueryable<BaseItemEntity> baseQuery,
+ IEnumerable<KeyValuePair<string, string>> providerIds,
+ bool invert)
+ {
+ ArgumentNullException.ThrowIfNull(providerIds);
- if (oneOf.Count < 4) // arbitrary value choosen.
+ var existenceOnly = new List<string>();
+ var specificValues = new List<string>();
+ foreach (var (provider, value) in providerIds)
{
- // 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);
+ if (string.IsNullOrEmpty(value))
+ {
+ existenceOnly.Add(provider);
+ }
+ else
+ {
+ specificValues.Add(provider + ":" + value);
+ }
}
- return Expression.Lambda<Func<TEntity, bool>>(Expression.Call(null, containsMethodInfo, Expression.Call(null, _efParameterInstruction.MakeGenericMethod(oneOf.GetType()), Expression.Constant(oneOf)), property.Body), parameter);
+ if (existenceOnly.Count == 0 && specificValues.Count == 0)
+ {
+ return baseQuery;
+ }
+
+ var predicate = ProviderPredicate(existenceOnly, specificValues);
+
+ // NOT EXISTS rather than NOT IN: the latter yields no rows at all if the subquery can produce NULL.
+ return invert
+ ? baseQuery.Where(e => !e.Provider!.AsQueryable().Any(predicate))
+ : baseQuery.Where(e => e.Provider!.AsQueryable().Any(predicate));
+ }
+
+ private static Expression<Func<BaseItemProvider, bool>> ProviderPredicate(
+ IReadOnlyList<string> existenceOnly,
+ IReadOnlyList<string> specificValues)
+ {
+ var byProvider = existenceOnly.OneOrManyExpressionBuilder<BaseItemProvider, string>(p => p.ProviderId);
+ var byPair = specificValues.OneOrManyExpressionBuilder<BaseItemProvider, string>(p => p.ProviderId + ":" + p.ProviderValue);
+
+ // Both builders mint their own parameter; rebind so the two bodies can share one lambda.
+ var parameter = byProvider.Parameters[0];
+ var reboundPair = ParameterReplacer.Replace<Func<BaseItemProvider, bool>, Func<BaseItemProvider, bool>>(byPair, byPair.Parameters[0], parameter);
+
+ return Expression.Lambda<Func<BaseItemProvider, bool>>(
+ Expression.OrElse(byProvider.Body, reboundPair.Body),
+ parameter);
}
internal static class ParameterReplacer
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs
index 76ffa5a9ea..29a073ff74 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs
@@ -88,13 +88,13 @@ public class OptimisticLockBehavior : IEntityFrameworkCoreLockingBehavior
/// <inheritdoc/>
public void OnSaveChanges(JellyfinDbContext context, Action saveChanges)
{
- _writePolicy.ExecuteAndCapture(saveChanges);
+ _writePolicy.Execute(saveChanges);
}
/// <inheritdoc/>
public async Task OnSaveChangesAsync(JellyfinDbContext context, Func<Task> saveChanges)
{
- await _writeAsyncPolicy.ExecuteAndCaptureAsync(saveChanges).ConfigureAwait(false);
+ await _writeAsyncPolicy.ExecuteAsync(saveChanges).ConfigureAwait(false);
}
private sealed class TransactionLockingInterceptor : DbTransactionInterceptor
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs
index 404292e8eb..e7a7d5a53f 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs
@@ -17,6 +17,13 @@ namespace Jellyfin.Database.Implementations.Locking;
/// <summary>
/// A locking behavior that will always block any operation while a write is requested. Mimicks the old SqliteRepository behavior.
/// </summary>
+/// <remarks>
+/// Unsafe with asynchronous transactions; because <see cref="ReaderWriterLockSlim"/> is
+/// thread-affine, holding it from <c>TransactionStarting</c> to <c>TransactionCommitted</c>
+/// works only while continuations resume inline. A genuinely-async continuation inside a
+/// transaction releases on another thread, throwing
+/// <see cref="SynchronizationLockException"/> or deadlocking a later write.
+/// </remarks>
public class PessimisticLockBehavior : IEntityFrameworkCoreLockingBehavior
{
private readonly ILogger<PessimisticLockBehavior> _logger;
@@ -47,7 +54,8 @@ public class PessimisticLockBehavior : IEntityFrameworkCoreLockingBehavior
/// <inheritdoc/>
public void Initialise(DbContextOptionsBuilder optionsBuilder)
{
- _logger.LogInformation("The database locking mode has been set to: Pessimistic.");
+ _logger.LogWarning(
+ "The database locking mode has been set to: Pessimistic. This mode is not safe with asynchronous transactions and can deadlock.");
optionsBuilder.AddInterceptors(new CommandLockingInterceptor(_loggerFactory.CreateLogger<CommandLockingInterceptor>()));
optionsBuilder.AddInterceptors(new TransactionLockingInterceptor(_loggerFactory.CreateLogger<TransactionLockingInterceptor>()));
}
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/LinkedChildConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/LinkedChildConfiguration.cs
index 2abccd41f0..b4013a394f 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/LinkedChildConfiguration.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/LinkedChildConfiguration.cs
@@ -13,8 +13,7 @@ public class LinkedChildConfiguration : IEntityTypeConfiguration<LinkedChildEnti
public void Configure(EntityTypeBuilder<LinkedChildEntity> builder)
{
builder.ToTable("LinkedChildren");
- builder.HasKey(e => new { e.ParentId, e.ChildId });
- builder.HasIndex(e => new { e.ParentId, e.SortOrder });
+ builder.HasKey(e => new { e.ParentId, e.SortOrder });
builder.HasIndex(e => new { e.ParentId, e.ChildType });
builder.HasIndex(e => new { e.ChildId, e.ChildType });
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs
index 32ede86c96..7ebdbf4e8b 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs
@@ -15,7 +15,7 @@ public class PeopleBaseItemMapConfiguration : IEntityTypeConfiguration<PeopleBas
builder.HasKey(e => new { e.ItemId, e.PeopleId, e.Role });
builder.HasIndex(e => new { e.ItemId, e.SortOrder });
builder.HasIndex(e => new { e.ItemId, e.ListOrder });
- builder.HasIndex(e => e.PeopleId);
+ builder.HasIndex(e => new { e.PeopleId, e.ItemId });
builder.HasOne(e => e.Item);
builder.HasOne(e => e.People);
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs
index d2aed54eb1..ae53a36724 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs
@@ -14,10 +14,8 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
{
// Used to get a user's permissions or a specific permission for a user.
// Also prevents multiple values being created for a user.
- // Filtered over non-null user ids for when other entities (groups, API keys) get permissions
builder
.HasIndex(p => new { p.UserId, p.Kind })
- .HasFilter("[UserId] IS NOT NULL")
.IsUnique();
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs
index 207051bcd1..5306078ed4 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs
@@ -14,7 +14,6 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration
{
builder
.HasIndex(p => new { p.UserId, p.Kind })
- .HasFilter("[UserId] IS NOT NULL")
.IsUnique();
}
}
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();
}
}
}