aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server.Implementations
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Server.Implementations')
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemMapper.cs36
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs37
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs69
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs4
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs37
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemCountService.cs40
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs142
-rw-r--r--Jellyfin.Server.Implementations/Item/NextUpService.cs23
-rw-r--r--Jellyfin.Server.Implementations/Item/PeopleRepository.cs75
-rw-r--r--Jellyfin.Server.Implementations/Security/AuthenticationManager.cs15
-rw-r--r--Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs14
-rw-r--r--Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs24
-rw-r--r--Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs75
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs10
14 files changed, 474 insertions, 127 deletions
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
index c2cb644c59..6405c8c45d 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
@@ -34,6 +34,40 @@ public static class BaseItemMapper
/// </summary>
private static readonly ConcurrentDictionary<string, Type?> _typeMap = new ConcurrentDictionary<string, Type?>();
+ private static UserData[] DetachUserData(BaseItemEntity entity)
+ {
+ if (entity.UserData is null || entity.UserData.Count == 0)
+ {
+ return [];
+ }
+
+ var detached = new UserData[entity.UserData.Count];
+ var index = 0;
+ foreach (var userData in entity.UserData)
+ {
+ detached[index++] = new UserData
+ {
+ ItemId = userData.ItemId,
+ Item = null,
+ UserId = userData.UserId,
+ User = null,
+ CustomDataKey = userData.CustomDataKey,
+ Rating = userData.Rating,
+ PlaybackPositionTicks = userData.PlaybackPositionTicks,
+ PlayCount = userData.PlayCount,
+ IsFavorite = userData.IsFavorite,
+ LastPlayedDate = userData.LastPlayedDate,
+ Played = userData.Played,
+ AudioStreamIndex = userData.AudioStreamIndex,
+ SubtitleStreamIndex = userData.SubtitleStreamIndex,
+ Likes = userData.Likes,
+ RetentionDate = userData.RetentionDate
+ };
+ }
+
+ return detached;
+ }
+
/// <summary>
/// Maps a Entity to the DTO.
/// </summary>
@@ -87,7 +121,7 @@ public static class BaseItemMapper
dto.OwnerId = entity.OwnerId ?? Guid.Empty;
dto.Width = entity.Width.GetValueOrDefault();
dto.Height = entity.Height.GetValueOrDefault();
- dto.UserData = entity.UserData;
+ dto.UserData = DetachUserData(entity);
if (entity.Provider is not null)
{
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
index cdc8744642..4e5d561f0a 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
@@ -117,6 +117,35 @@ public sealed partial class BaseItemRepository
.ToArray();
}
+ /// <inheritdoc />
+ public IReadOnlyList<string> GetTagNames(InternalItemsQuery filter)
+ {
+ ArgumentNullException.ThrowIfNull(filter);
+ PrepareFilterQuery(filter);
+
+ using var context = _dbProvider.CreateDbContext();
+ var baseQuery = PrepareItemQuery(context, filter);
+ baseQuery = TranslateQuery(baseQuery, context, filter);
+
+ var matchingItemIds = baseQuery.Select(e => e.Id);
+
+ // Project the join before grouping. Grouping over the ItemValue navigation instead makes EF
+ // re-resolve the aggregate as a correlated subquery per group, which is orders of magnitude slower.
+ return context.ItemValuesMap
+ .AsNoTracking()
+ .Join(
+ context.ItemValues,
+ ivm => ivm.ItemValueId,
+ iv => iv.ItemValueId,
+ (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value })
+ .Where(iv => iv.Type == ItemValueType.Tags)
+ .Where(iv => matchingItemIds.Contains(iv.ItemId))
+ .GroupBy(iv => iv.CleanValue)
+ .Select(g => g.Min(iv => iv.Value)!)
+ .OrderBy(t => t)
+ .ToArray();
+ }
+
private string[] GetItemValueNames(IReadOnlyList<ItemValueType> itemValueTypes, IReadOnlyList<string> withItemTypes, IReadOnlyList<string> excludeItemTypes)
{
using var context = _dbProvider.CreateDbContext();
@@ -183,6 +212,7 @@ public sealed partial class BaseItemRepository
IsFavoriteOrLiked = filter.IsFavoriteOrLiked,
IsLiked = filter.IsLiked,
IsLocked = filter.IsLocked,
+ ImageTypes = filter.ImageTypes,
NameLessThan = filter.NameLessThan,
NameStartsWith = filter.NameStartsWith,
NameStartsWithOrGreater = filter.NameStartsWithOrGreater,
@@ -194,7 +224,12 @@ public sealed partial class BaseItemRepository
Years = filter.Years,
NameContains = filter.NameContains,
SearchTerm = filter.SearchTerm,
- ExcludeItemIds = filter.ExcludeItemIds
+ ExcludeItemIds = filter.ExcludeItemIds,
+
+ // A genre, studio or artist carries none of the tags of the media it describes, so an
+ // allow list can only ever hide all of them. Reachability is settled by innerQueryFilter
+ // instead: a value gets this far only when an item the user may see carries it.
+ IncludeInheritedTags = []
};
// Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) into one
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
index c0067d8392..4cd02bcd8d 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
@@ -271,7 +271,7 @@ public sealed partial class BaseItemRepository
if (filter.DtoOptions.EnableImages)
{
- dbQuery = dbQuery.Include(e => e.Images);
+ dbQuery = dbQuery.Include(e => e.Images!.OrderBy(i => i.Id));
}
// Include LinkedChildEntities for container types and videos that use them (BoxSet, Playlist,
@@ -291,7 +291,7 @@ public sealed partial class BaseItemRepository
};
if (filter.IncludeItemTypes.Length == 0 || filter.IncludeItemTypes.Any(linkedChildTypes.Contains))
{
- dbQuery = dbQuery.Include(e => e.LinkedChildEntities);
+ dbQuery = dbQuery.Include(e => e.LinkedChildEntities!.OrderBy(l => l.SortOrder));
}
if (filter.IncludeExtras)
@@ -465,16 +465,23 @@ public sealed partial class BaseItemRepository
baseQuery = ApplyParentalRestrictions(context, baseQuery, filter);
- // Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items.
- // Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those.
+ // Hide alternate versions behind the primary of their library, and exclude owned non-extra
+ // items. Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those.
if (!filter.IncludeOwnedItems)
{
- baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
+ baseQuery = ApplyAlternateVersionFiltering(context, baseQuery)
+ .Where(e => e.OwnerId == null || e.ExtraType != null);
}
return baseQuery;
}
+ private static IQueryable<BaseItemEntity> ApplyAlternateVersionFiltering(
+ JellyfinDbContext context,
+ IQueryable<BaseItemEntity> baseQuery)
+ => baseQuery.Where(e => e.PrimaryVersionId == null
+ || !context.BaseItems.Any(p => p.Id == e.PrimaryVersionId && p.TopParentId == e.TopParentId));
+
/// <summary>
/// Restricts a query to the libraries the user may open, exempting requested by-name items.
/// </summary>
@@ -605,11 +612,12 @@ public sealed partial class BaseItemRepository
var blockedTagItemIds = context.ItemValuesMap
.Where(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))
.Select(f => f.ItemId);
+ var blockedByAncestor = ItemsBelowTaggedAncestor(context, blockedTagItemIds);
baseQuery = baseQuery.Where(e =>
!blockedTagItemIds.Contains(e.Id)
&& !(e.SeriesId.HasValue && blockedTagItemIds.Contains(e.SeriesId.Value))
- && !e.Parents!.Any(p => blockedTagItemIds.Contains(p.ParentItemId))
+ && !blockedByAncestor.Contains(e.Id)
&& !(e.TopParentId.HasValue && blockedTagItemIds.Contains(e.TopParentId.Value)));
}
@@ -622,10 +630,12 @@ public sealed partial class BaseItemRepository
.Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
.Select(f => f.ItemId);
+ var allowedByAncestor = ItemsBelowTaggedAncestor(context, allowedTagItemIds);
+
baseQuery = baseQuery.Where(e =>
allowedTagItemIds.Contains(e.Id)
|| (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value))
- || e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId))
+ || allowedByAncestor.Contains(e.Id)
|| (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value))
// People don't carry the tags of the media they appear in and would never match
@@ -636,6 +646,17 @@ public sealed partial class BaseItemRepository
}
/// <summary>
+ /// Reads back the items that carry one of the tagged items as an ancestor.
+ /// </summary>
+ /// <param name="context">The database context.</param>
+ /// <param name="taggedItemIds">The ids of the items carrying the tag.</param>
+ /// <returns>The ids of the items below one of them.</returns>
+ private static IQueryable<Guid> ItemsBelowTaggedAncestor(JellyfinDbContext context, IQueryable<Guid> taggedItemIds)
+ => context.AncestorIds
+ .Where(a => taggedItemIds.Contains(a.ParentItemId))
+ .Select(a => a.ItemId);
+
+ /// <summary>
/// Builds a filter expression for max parental rating that handles both rated items
/// and unrated BoxSets/Playlists (which check linked children's ratings).
/// </summary>
@@ -645,24 +666,26 @@ public sealed partial class BaseItemRepository
{
var maxScore = maxRating.Score;
var maxSubScore = maxRating.SubScore ?? 0;
- var linkedChildren = context.LinkedChildren;
+
+ // Only a manual link makes an item a container of other items.
+ var members = context.LinkedChildren
+ .Where(lc => lc.ChildType == Database.Implementations.Entities.LinkedChildType.Manual);
return e =>
- // Item has a rating: check against limit
- (e.InheritedParentalRatingValue != null
- && (e.InheritedParentalRatingValue < maxScore
- || (e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore)))
- // Item has no rating
- || (e.InheritedParentalRatingValue == null
- && (
- // No linked children (not a BoxSet/Playlist): pass as unrated
- !linkedChildren.Any(lc => lc.ParentId == e.Id)
- // Has linked children: at least one child must be within limits
- || linkedChildren.Any(lc => lc.ParentId == e.Id
- && (lc.Child!.InheritedParentalRatingValue == null
- || lc.Child.InheritedParentalRatingValue < maxScore
- || (lc.Child.InheritedParentalRatingValue == maxScore
- && (lc.Child.InheritedParentalRatingSubValue ?? 0) <= maxSubScore)))));
+ // The item's own rating, where it has one, has to be within the limit. An unrated item
+ // passes here; blocking those is what BlockUnratedItems does.
+ (e.InheritedParentalRatingValue == null
+ || e.InheritedParentalRatingValue < maxScore
+ || (e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore))
+ // A container is only as visible as its members: a BoxSet or Playlist with nothing left
+ // in it for this user is hidden whatever rating it carries itself. BoxSet.IsVisible
+ // applies the same rule in memory, and a count has to agree with the listing it counts.
+ && (!members.Any(lc => lc.ParentId == e.Id)
+ || members.Any(lc => lc.ParentId == e.Id
+ && (lc.Child!.InheritedParentalRatingValue == null
+ || lc.Child.InheritedParentalRatingValue < maxScore
+ || (lc.Child.InheritedParentalRatingValue == maxScore
+ && (lc.Child.InheritedParentalRatingSubValue ?? 0) <= maxSubScore))));
}
/// <inheritdoc />
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
index 1ed10cce2b..8d573569e9 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
@@ -593,10 +593,10 @@ public sealed partial class BaseItemRepository
return dbContext.BaseItems
.Where(e => descendantIds.Contains(e.Id) && !e.IsFolder && !e.IsVirtualItem)
- .All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played));
+ .All(BuildLeafIsPlayedFilter(dbContext, user.Id));
}
- return dbContext.BaseItems.Where(e => e.ParentId == id).All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played));
+ return dbContext.BaseItems.Where(e => e.ParentId == id).All(BuildLeafIsPlayedFilter(dbContext, user.Id));
}
/// <inheritdoc />
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index a745c3309f..e66f18bc6e 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -476,16 +476,8 @@ public sealed partial class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.Name))
{
- if (filter.UseRawName == true)
- {
- var nameLower = filter.Name.ToLowerInvariant();
- baseQuery = baseQuery.Where(e => e.Name!.ToLower() == nameLower);
- }
- else
- {
- var cleanName = filter.Name.GetCleanValue();
- baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
- }
+ var cleanName = filter.Name.GetCleanValue();
+ baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
}
var nameContains = filter.NameContains;
@@ -581,8 +573,8 @@ public sealed partial class BaseItemRepository
.ToArray();
var folderIsResumableFilter = IsFolderFilter.And(e => resumableFolderTypes.Contains(e.Type))
.And(BuildHasDescendantFilter(context, inProgressLeafItems)
- .Or(BuildHasDescendantFilter(context, leafItems.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))
- .And(BuildHasDescendantFilter(context, leafItems.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))))));
+ .Or(BuildHasDescendantFilter(context, leafItems.Where(BuildLeafIsPlayedFilter(context, userId)))
+ .And(BuildHasDescendantFilter(context, leafItems.Where(BuildLeafIsPlayedFilter(context, userId).Not())))));
if (isResumable)
{
@@ -807,11 +799,16 @@ public sealed partial class BaseItemRepository
{
// Exclude owned non-extra items from general queries.
// Extras (trailers, etc.) have OwnerId set but also have ExtraType set - keep those.
- // Alternate versions (PrimaryVersionId set) are normally excluded too, but resume queries
- // keep them so the actually-played version can surface instead of collapsing onto the primary.
- baseQuery = filter.IsResumable == true
- ? baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null)
- : baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
+ baseQuery = baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null);
+
+ // Alternate versions (PrimaryVersionId set) are normally hidden behind their primary, but
+ // resume queries keep them so the actually-played version can surface instead of collapsing
+ // onto the primary, and the library scan keeps them so a merged version is not mistaken for
+ // a new item.
+ if (filter.IsResumable != true && !filter.IncludeAlternateVersions)
+ {
+ baseQuery = ApplyAlternateVersionFiltering(context, baseQuery);
+ }
}
if (filter.OwnerIds.Length > 0)
@@ -1135,11 +1132,12 @@ public sealed partial class BaseItemRepository
var blockedTagItemIds = context.ItemValuesMap
.Where(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))
.Select(f => f.ItemId);
+ var blockedByAncestor = ItemsBelowTaggedAncestor(context, blockedTagItemIds);
baseQuery = baseQuery.Where(e =>
!blockedTagItemIds.Contains(e.Id)
&& !(e.SeriesId.HasValue && blockedTagItemIds.Contains(e.SeriesId.Value))
- && !e.Parents!.Any(p => blockedTagItemIds.Contains(p.ParentItemId))
+ && !blockedByAncestor.Contains(e.Id)
&& !(e.TopParentId.HasValue && blockedTagItemIds.Contains(e.TopParentId.Value)));
}
@@ -1151,11 +1149,12 @@ public sealed partial class BaseItemRepository
var allowedTagItemIds = context.ItemValuesMap
.Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
.Select(f => f.ItemId);
+ var allowedByAncestor = ItemsBelowTaggedAncestor(context, allowedTagItemIds);
baseQuery = baseQuery.Where(e =>
allowedTagItemIds.Contains(e.Id)
|| (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value))
- || e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId))
+ || allowedByAncestor.Contains(e.Id)
|| (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value))
// People don't carry the tags of the media they appear in and would never match
diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
index 57705cdf11..942161a176 100644
--- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
@@ -414,7 +414,7 @@ public class ItemCountService : IItemCountService
using var dbContext = _dbProvider.CreateDbContext();
var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
- return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played));
+ return baseQuery.Count(DescendantQueryHelper.IsPlayedBy(filter.User.Id));
}
/// <inheritdoc/>
@@ -483,7 +483,19 @@ public class ItemCountService : IItemCountService
var includeVirtual = user is null || user.DisplayMissingEpisodes;
- var hierarchicalCounts = dbContext.BaseItems
+ var accessibleItems = dbContext.BaseItems.AsNoTracking();
+ if (user is null)
+ {
+ // Access filtering is what would otherwise drop an alternate version, and a child count
+ // must not report a title twice just because no user was passed in.
+ accessibleItems = accessibleItems.Where(DescendantQueryHelper.IsDistinctLibraryItem);
+ }
+ else
+ {
+ accessibleItems = _queryHelpers.ApplyAccessFiltering(dbContext, accessibleItems, new InternalItemsQuery(user));
+ }
+
+ var hierarchicalCounts = accessibleItems
.Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
.WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value)
.GroupBy(b => b.ParentId!.Value)
@@ -493,20 +505,22 @@ public class ItemCountService : IItemCountService
// An episode is a child of its season even when it is not stored under one: with a flat
// structure ParentId points at the series, so counting by ParentId alone leaves the season
// empty and counts its episodes towards the series instead.
- var seasonCounts = dbContext.BaseItems
+ var seasonCounts = accessibleItems
.Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
.WhereOneOrMany(parentIdsArray, b => b.SeasonId!.Value)
.GroupBy(b => b.SeasonId!.Value)
.Select(g => new { SeasonId = g.Key, Count = g.Count() })
.ToDictionary(x => x.SeasonId, x => x.Count);
+ // A linked child counts only when the item it points at is one the user may open.
var linkedCounts = dbContext.LinkedChildren
.WhereOneOrMany(parentIdsArray, lc => lc.ParentId)
- .GroupBy(lc => lc.ParentId)
+ .Join(accessibleItems, lc => lc.ChildId, b => b.Id, (lc, b) => lc.ParentId)
+ .GroupBy(parentId => parentId)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
- var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray, includeVirtual);
+ var mergedChildCounts = GetMergedChildCounts(dbContext, accessibleItems, parentIdsArray, includeVirtual);
var result = new Dictionary<Guid, int>();
foreach (var parentId in parentIds)
@@ -527,7 +541,11 @@ public class ItemCountService : IItemCountService
return result;
}
- private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds, bool includeVirtual)
+ private static Dictionary<Guid, int> GetMergedChildCounts(
+ JellyfinDbContext dbContext,
+ IQueryable<BaseItemEntity> accessibleItems,
+ IReadOnlyList<Guid> parentIds,
+ bool includeVirtual)
{
var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds)
.Where(group => group.Value.Count > 1)
@@ -540,14 +558,12 @@ public class ItemCountService : IItemCountService
// Only merged folders.
var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray();
- var children = dbContext.BaseItems
- .AsNoTracking()
+ var children = accessibleItems
.Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
.WhereOneOrMany(memberIds, b => b.ParentId!.Value)
.Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey })
.ToArray()
- .Concat(dbContext.BaseItems
- .AsNoTracking()
+ .Concat(accessibleItems
.Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
.WhereOneOrMany(memberIds, b => b.SeasonId!.Value)
.Select(b => new { ParentId = b.SeasonId!.Value, b.Id, b.PresentationUniqueKey })
@@ -601,7 +617,7 @@ public class ItemCountService : IItemCountService
leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter);
var playedLeafItems = leafItems
- .Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) });
+ .Select(DescendantQueryHelper.PlayedStateBy(userId));
var ancestorLeaves = dbContext.AncestorIds
.WhereOneOrMany(folderIdsArray, a => a.ParentItemId)
@@ -719,7 +735,7 @@ public class ItemCountService : IItemCountService
private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId)
{
var result = query
- .Select(b => b.UserData!.Any(u => u.UserId == userId && u.Played))
+ .Select(DescendantQueryHelper.IsPlayedBy(userId))
.GroupBy(_ => 1)
.OrderBy(g => g.Key)
.Select(g => new
diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
index c8672e189b..3bf33b969e 100644
--- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -68,16 +69,24 @@ public class ItemPersistenceService : IItemPersistenceService
// Use WhereOneOrMany instead of a raw HashSet.Contains so large id sets are bound as a
// single parameter (json_each) rather than one SQL variable per id, which would otherwise
// overflow SQLite's variable limit when deleting many items at once (e.g. migrations).
- var ownerIds = descendantIds.ToArray();
- var extraIds = context.BaseItems
- .Where(e => e.OwnerId.HasValue)
- .WhereOneOrMany(ownerIds, e => e.OwnerId!.Value)
- .Select(e => e.Id)
- .ToArray();
-
- foreach (var extraId in extraIds)
+ var frontier = descendantIds.ToArray();
+ while (frontier.Length > 0)
{
- descendantIds.Add(extraId);
+ var ownedIds = context.BaseItems
+ .Where(e => e.OwnerId.HasValue)
+ .WhereOneOrMany(frontier, e => e.OwnerId!.Value)
+ .Select(e => e.Id)
+ .ToArray();
+
+ var childIds = context.BaseItems
+ .Where(e => e.ParentId.HasValue)
+ .WhereOneOrMany(frontier, e => e.ParentId!.Value)
+ .Select(e => e.Id)
+ .ToArray();
+
+ // Only ids that were not already known become the next frontier, so ownership cycles
+ // terminate instead of looping forever.
+ frontier = [.. ownedIds.Concat(childIds).Where(e => descendantIds.Add(e))];
}
var relatedItems = descendantIds.ToArray();
@@ -132,17 +141,17 @@ public class ItemPersistenceService : IItemPersistenceService
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.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ParentId).ExecuteDelete();
context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ChildId).ExecuteDelete();
+ var peopleIds = context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).Select(f => f.PeopleId).Distinct().ToArray();
context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDelete();
+ context.ItemValues.Where(e => !e.BaseItemsMap!.Any()).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.Peoples.WhereOneOrMany(peopleIds, e => e.Id).Where(e => !e.BaseItems!.Any()).ExecuteDelete();
context.TrickplayInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.SaveChanges();
transaction.Commit();
@@ -221,17 +230,18 @@ public class ItemPersistenceService : IItemPersistenceService
var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using (transaction.ConfigureAwait(false))
{
- var userKeys = item.GetUserDataKeys().ToArray();
- var retentionDate = (DateTime?)null;
+ var userKeys = item.GetUserDataKeys().Distinct().ToList();
- await dbContext.UserData
+ var detached = await dbContext.UserData
.Where(e => e.ItemId == BaseItemRepository.PlaceholderId)
.Where(e => userKeys.Contains(e.CustomDataKey))
- .ExecuteUpdateAsync(
- e => e
- .SetProperty(f => f.ItemId, item.Id)
- .SetProperty(f => f.RetentionDate, retentionDate),
- cancellationToken).ConfigureAwait(false);
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ if (detached.Count > 0)
+ {
+ await ReconcileUserDataAsync(dbContext, item, userKeys, detached, cancellationToken).ConfigureAwait(false);
+ }
item.UserData = await dbContext.UserData
.AsNoTracking()
@@ -244,6 +254,62 @@ public class ItemPersistenceService : IItemPersistenceService
}
}
+ private static async Task ReconcileUserDataAsync(
+ JellyfinDbContext dbContext,
+ BaseItemDto item,
+ IReadOnlyList<string> userKeys,
+ List<UserData> detached,
+ CancellationToken cancellationToken)
+ {
+ var existing = await dbContext.UserData
+ .Where(e => e.ItemId == item.Id)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ // Keys the item no longer reports are carried over: they may only be missing mid-refresh.
+ var winners = detached.Concat(existing)
+ .GroupBy(e => e.UserId)
+ .Select(g => (
+ Winner: g
+ .OrderByDescending(e => e.LastPlayedDate)
+ .ThenByDescending(e => e.PlayCount)
+ .ThenByDescending(e => e.PlaybackPositionTicks)
+ .First(),
+ Keys: userKeys.Union(g.Select(e => e.CustomDataKey)).ToList()))
+ .ToList();
+
+ dbContext.UserData.RemoveRange(detached);
+ dbContext.UserData.RemoveRange(existing);
+ await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+
+ foreach (var (winner, keys) in winners)
+ {
+ foreach (var key in keys)
+ {
+ dbContext.UserData.Add(new UserData
+ {
+ ItemId = item.Id,
+ Item = null,
+ UserId = winner.UserId,
+ User = null,
+ CustomDataKey = key,
+ RetentionDate = null,
+ AudioStreamIndex = winner.AudioStreamIndex,
+ IsFavorite = winner.IsFavorite,
+ LastPlayedDate = winner.LastPlayedDate,
+ Likes = winner.Likes,
+ PlaybackPositionTicks = winner.PlaybackPositionTicks,
+ PlayCount = winner.PlayCount,
+ Played = winner.Played,
+ Rating = winner.Rating,
+ SubtitleStreamIndex = winner.SubtitleStreamIndex
+ });
+ }
+ }
+
+ await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
private void UpdateOrInsertItems(IReadOnlyList<BaseItemDto> items, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(items);
@@ -268,7 +334,7 @@ public class ItemPersistenceService : IItemPersistenceService
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).ToHashSet();
+ var existingItems = context.BaseItems.WhereOneOrMany(ids, e => e.Id).Select(f => f.Id).ToHashSet();
foreach (var item in tuples)
{
@@ -328,7 +394,7 @@ public class ItemPersistenceService : IItemPersistenceService
.Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore[(e.MagicNumber, e.Value)]).DistinctBy(e => e.ItemValueId).ToArray()))
.ToArray();
- var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList();
+ var mappedValues = context.ItemValuesMap.WhereOneOrMany(ids, e => e.ItemId).ToList();
foreach (var item in valueMap)
{
@@ -657,6 +723,38 @@ public class ItemPersistenceService : IItemPersistenceService
sortOrder++;
}
+ var linkedChildIds = newLinkedChildren
+ .Select(c => c.ChildId)
+ // A video listed among its own versions would be pointed at itself.
+ .Where(childId => existingChildIds.Contains(childId) && !childId.Equals(video.Id))
+ .Where(childId => !childId.Equals(video.PrimaryVersionId))
+ .ToList();
+ if (linkedChildIds.Count > 0)
+ {
+ var demotedChildren = context.BaseItems
+ .Where(e => linkedChildIds.Contains(e.Id)
+ && (e.PrimaryVersionId == null || e.PrimaryVersionId != video.Id))
+ .ToList();
+
+ foreach (var child in demotedChildren)
+ {
+ child.PrimaryVersionId = video.Id;
+
+ // Mirrors Video.CreatePresentationUniqueKey, so presentation-key grouping
+ // collapses the version onto its primary as well.
+ child.PresentationUniqueKey = video.Id.ToString("N", CultureInfo.InvariantCulture);
+ }
+
+ if (demotedChildren.Count > 0)
+ {
+ _logger.LogInformation(
+ "Set PrimaryVersionId on {Count} alternate versions of video {VideoName} ({VideoId})",
+ demotedChildren.Count,
+ video.Name,
+ video.Id);
+ }
+ }
+
// A previously-linked LocalAlternateVersion that is no longer present becomes orphaned;
var previousLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(video.Id);
if (previousLinkedChildren is { Count: > 0 })
diff --git a/Jellyfin.Server.Implementations/Item/NextUpService.cs b/Jellyfin.Server.Implementations/Item/NextUpService.cs
index f478daef23..897fb98cbb 100644
--- a/Jellyfin.Server.Implementations/Item/NextUpService.cs
+++ b/Jellyfin.Server.Implementations/Item/NextUpService.cs
@@ -95,7 +95,7 @@ public class NextUpService : INextUpService
.Where(e => e.Type == episodeTypeName)
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber != 0)
- .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
+ .Where(DescendantQueryHelper.IsPlayedBy(userId));
lastWatchedBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedBase, filter);
// Use lightweight projection + client-side dedup to avoid the correlated scalar subquery
@@ -129,12 +129,21 @@ public class NextUpService : INextUpService
// Use an explicit Join (INNER JOIN) instead of SelectMany on a collection navigation.
// SelectMany on UserData with a correlated Where would translate to APPLY,
// which SQLite does not support.
+ // Access filtering leaves only primaries in the base query, but a play can be recorded
+ // against any version, so each row is attributed to its group's primary before the join.
+ var playedByGroupPrimary = context.UserData
+ .AsNoTracking()
+ .Where(ud => ud.ItemId != EF.Constant(BaseItemRepository.PlaceholderId))
+ .Where(ud => ud.Played)
+ .Join(
+ context.BaseItems.AsNoTracking(),
+ ud => ud.ItemId,
+ bi => bi.Id,
+ (ud, bi) => new { ud.UserId, ItemId = bi.PrimaryVersionId ?? bi.Id, ud.LastPlayedDate });
+
var playedWithDates = lastWatchedByDateBase
.Join(
- context.UserData
- .AsNoTracking()
- .Where(ud => ud.ItemId != EF.Constant(BaseItemRepository.PlaceholderId))
- .Where(ud => ud.Played),
+ playedByGroupPrimary,
e => new { UserId = userId, ItemId = e.Id },
ud => new { ud.UserId, ud.ItemId },
(e, ud) => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate })
@@ -198,7 +207,7 @@ public class NextUpService : INextUpService
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber != 0)
.Where(e => !e.IsVirtualItem)
- .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
+ .Where(DescendantQueryHelper.IsUnplayedBy(userId));
allUnplayedBase = _queryHelpers.ApplyAccessFiltering(context, allUnplayedBase, filter);
var allUnplayedCandidates = allUnplayedBase
.Select(e => new
@@ -246,7 +255,7 @@ public class NextUpService : INextUpService
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber != 0)
.Where(e => !e.IsVirtualItem)
- .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
+ .Where(DescendantQueryHelper.IsPlayedBy(userId));
allPlayedBase = _queryHelpers.ApplyAccessFiltering(context, allPlayedBase, filter);
var allPlayedCandidates = allPlayedBase
.Select(e => new
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index da2ad033ec..802138ed5e 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -127,18 +127,61 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
var distinctCredits = credits.DistinctBy(e => (e.LoweredName, e.PersonType, e.LoweredRole)).ToArray();
var distinctPersons = distinctCredits.DistinctBy(e => (e.LoweredName, e.PersonType)).ToArray();
- var personKeys = distinctPersons.Select(e => e.LoweredName + "-" + e.PersonType).ToArray();
using var context = _dbProvider.CreateDbContext();
+ var existingMaps = context.PeopleBaseItemMap
+ .AsNoTracking()
+ .Include(e => e.People)
+ .Where(e => e.ItemId == itemId)
+ .ToList();
+
+ // Most library scans refresh unchanged local metadata. Avoid opening a write
+ // transaction when the item's people mappings, order and roles are unchanged.
+ var incomingCredits = distinctCredits
+ .Select((credit, index) => new
+ {
+ Key = (credit.LoweredName, credit.PersonType, credit.LoweredRole),
+ Role = credit.Person.Role,
+ ListOrder = index,
+ SortOrder = credit.Person.SortOrder
+ })
+ .ToDictionary(e => e.Key);
+ var mappingsAreUnchanged = existingMaps.Count == incomingCredits.Count
+ && existingMaps.All(map =>
+ incomingCredits.TryGetValue(
+ (map.People.Name.ToLowerInvariant(), map.People.PersonType ?? string.Empty, map.Role?.ToLowerInvariant() ?? string.Empty),
+ out var incoming)
+ && map.ListOrder == incoming.ListOrder
+ && map.SortOrder == incoming.SortOrder
+ && string.Equals(map.Role ?? string.Empty, incoming.Role, StringComparison.OrdinalIgnoreCase));
+
+ if (mappingsAreUnchanged)
+ {
+ return;
+ }
+
using var transaction = context.Database.BeginTransaction();
- var existingPersons = context.Peoples.Select(e => new
+ // The fast-path snapshot was read before acquiring the write transaction. Reload
+ // tracked mappings inside it so a concurrent refresh cannot leave stale credits.
+ existingMaps = context.PeopleBaseItemMap
+ .Include(e => e.People)
+ .Where(e => e.ItemId == itemId)
+ .ToList();
+
+ // Query each person type separately so SQLite can use IX_Peoples_NameLower.
+ // Combining the two fields into `lower(Name) || '-' || PersonType` forces a full
+ // scan of Peoples for every media item, which is prohibitive during a large import.
+ var existingPersons = new List<People>();
+ foreach (var personTypeGroup in distinctPersons.GroupBy(e => e.PersonType, StringComparer.Ordinal))
{
- item = e,
- SelectionKey = e.Name.ToLower() + "-" + e.PersonType
- })
- .Where(p => personKeys.Contains(p.SelectionKey))
- .Select(f => f.item)
- .ToArray();
+ var names = personTypeGroup
+ .Select(e => e.LoweredName)
+ .ToArray();
+
+ existingPersons.AddRange(context.Peoples
+ .Where(e => e.PersonType == personTypeGroup.Key && names.Contains(e.Name.ToLower()))
+ .ToArray());
+ }
var existingPersonKeys = existingPersons.Select(e => (e.Name.ToLowerInvariant(), e.PersonType ?? string.Empty)).ToHashSet();
@@ -157,7 +200,6 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
personsEntities.TryAdd((entity.Name.ToLowerInvariant(), entity.PersonType ?? string.Empty), entity);
}
- var existingMaps = context.PeopleBaseItemMap.Include(e => e.People).Where(e => e.ItemId == itemId).ToList();
var existingMapsByCredit = new Dictionary<(string LoweredName, string PersonType, string LoweredRole), PeopleBaseItemMap>();
foreach (var map in existingMaps)
{
@@ -238,7 +280,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
using var context = _dbProvider.CreateDbContext();
var query = context.PeopleBaseItemMap
.AsNoTracking()
- .Where(m => itemIds.Contains(m.ItemId));
+ .WhereOneOrMany(itemIds, m => m.ItemId);
if (personTypes.Count > 0)
{
@@ -274,7 +316,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
using var context = _dbProvider.CreateDbContext();
var rows = context.PeopleBaseItemMap
.AsNoTracking()
- .Where(m => itemIds.Contains(m.ItemId))
+ .WhereOneOrMany(itemIds, m => m.ItemId)
.OrderBy(m => m.ListOrder)
.Select(m => new
{
@@ -381,6 +423,13 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
.Any(m => m.PeopleId == e.Id && accessibleItems.Any(i => i.Id == m.ItemId)));
}
+ if (filter.MustHaveItem)
+ {
+ // A credit with no by-name item behind it cannot be handed back as one.
+ var personType = itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
+ query = query.Where(e => context.BaseItems.Any(b => b.Type == personType && b.Name == e.Name));
+ }
+
if (!filter.ItemId.IsEmpty())
{
var itemId = filter.ItemId;
@@ -435,12 +484,12 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (!string.IsNullOrWhiteSpace(filter.NameLessThan))
{
- query = query.Where(e => e.Name.CompareTo(filter.NameLessThan.ToLowerInvariant()) < 0);
+ query = query.Where(e => e.Name.ToLower().CompareTo(filter.NameLessThan.ToLowerInvariant()) < 0);
}
if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater))
{
- query = query.Where(e => e.Name.CompareTo(filter.NameStartsWithOrGreater.ToLowerInvariant()) >= 0);
+ query = query.Where(e => e.Name.ToLower().CompareTo(filter.NameStartsWithOrGreater.ToLowerInvariant()) >= 0);
}
return query;
diff --git a/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs b/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs
index cf0293463f..2bc7b9f915 100644
--- a/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs
+++ b/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs
@@ -23,14 +23,25 @@ namespace Jellyfin.Server.Implementations.Security
}
/// <inheritdoc />
- public async Task CreateApiKey(string name)
+ public async Task<AuthenticationInfo> CreateApiKey(string name)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
- dbContext.ApiKeys.Add(new ApiKey(name));
+ var apiKey = new ApiKey(name);
+ dbContext.ApiKeys.Add(apiKey);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
+
+ return new AuthenticationInfo
+ {
+ AppName = apiKey.Name,
+ AccessToken = apiKey.AccessToken,
+ DateCreated = apiKey.DateCreated,
+ DeviceId = string.Empty,
+ DeviceName = string.Empty,
+ AppVersion = string.Empty
+ };
}
}
diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
index 58b9f7f822..3c1164bffe 100644
--- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
+++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
@@ -11,6 +11,7 @@ using AsyncKeyedLock;
using J2N.Collections.Generic.Extensions;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Drawing;
@@ -478,6 +479,14 @@ public partial class TrickplayManager : ITrickplayManager
var mediaStream = mediaSource.VideoStream;
var container = mediaSource.Container;
+ // Checks for write permission before generating images
+ if (saveWithMedia)
+ {
+ _logger.LogDebug("Verifying write permission in media directory {OutputDir}", outputDir);
+ Directory.CreateDirectory(outputDir.FullName);
+ FileHelper.CreateEmpty(Path.Combine(outputDir.FullName, ".jellyfin-trickplay"));
+ }
+
_logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", actualWidth, mediaPath, video.Id);
imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated(
mediaPath,
@@ -722,7 +731,10 @@ public partial class TrickplayManager : ITrickplayManager
public async Task DeleteTrickplayDataAsync(Guid itemId, CancellationToken cancellationToken)
{
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
- await dbContext.TrickplayInfos.Where(i => i.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ await dbContext.TrickplayInfos.Where(i => i.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ }
}
/// <inheritdoc />
diff --git a/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs b/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs
index 92e2bb4fa7..7c46ef7721 100644
--- a/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs
+++ b/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs
@@ -1,3 +1,4 @@
+using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data;
@@ -9,6 +10,7 @@ using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Implementations.Users;
@@ -20,6 +22,7 @@ public sealed class DeviceAccessHost : IHostedService
private readonly IUserManager _userManager;
private readonly IDeviceManager _deviceManager;
private readonly ISessionManager _sessionManager;
+ private readonly ILogger<DeviceAccessHost> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DeviceAccessHost"/> class.
@@ -27,11 +30,17 @@ public sealed class DeviceAccessHost : IHostedService
/// <param name="userManager">The <see cref="IUserManager"/>.</param>
/// <param name="deviceManager">The <see cref="IDeviceManager"/>.</param>
/// <param name="sessionManager">The <see cref="ISessionManager"/>.</param>
- public DeviceAccessHost(IUserManager userManager, IDeviceManager deviceManager, ISessionManager sessionManager)
+ /// <param name="logger">The <see cref="ILogger{TCategoryName}"/>.</param>
+ public DeviceAccessHost(
+ IUserManager userManager,
+ IDeviceManager deviceManager,
+ ISessionManager sessionManager,
+ ILogger<DeviceAccessHost> logger)
{
_userManager = userManager;
_deviceManager = deviceManager;
_sessionManager = sessionManager;
+ _logger = logger;
}
/// <inheritdoc />
@@ -53,9 +62,18 @@ public sealed class DeviceAccessHost : IHostedService
private async void OnUserUpdated(object? sender, GenericEventArgs<User> e)
{
var user = e.Argument;
- if (!user.HasPermission(PermissionKind.EnableAllDevices))
+
+ // This handler is async void, so an escaping exception would terminate the process.
+ try
+ {
+ if (!user.HasPermission(PermissionKind.EnableAllDevices))
+ {
+ await UpdateDeviceAccess(user).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
{
- await UpdateDeviceAccess(user).ConfigureAwait(false);
+ _logger.LogError(ex, "Error updating device access for user {UserId}", user.Id);
}
}
diff --git a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs
index 0e126fe9a0..dd3c63ae32 100644
--- a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs
+++ b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs
@@ -13,6 +13,8 @@ namespace Jellyfin.Server.Implementations.Users;
/// </summary>
public sealed class DisplayPreferencesManager : IDisplayPreferencesManager
{
+ private const int MaxSaveAttempts = 3;
+
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
/// <summary>
@@ -28,17 +30,31 @@ public sealed class DisplayPreferencesManager : IDisplayPreferencesManager
public DisplayPreferences GetDisplayPreferences(Guid userId, Guid itemId, string client)
{
using var dbContext = _dbContextFactory.CreateDbContext();
- var prefs = dbContext.DisplayPreferences
- .Include(pref => pref.HomeSections)
- .FirstOrDefault(pref =>
- pref.UserId.Equals(userId) && pref.Client == client && pref.ItemId.Equals(itemId));
+ var prefs = FindDisplayPreferences(dbContext, userId, itemId, client);
+ if (prefs is not null)
+ {
+ return prefs;
+ }
- if (prefs is null)
+ prefs = new DisplayPreferences(userId, itemId, client);
+ dbContext.DisplayPreferences.Add(prefs);
+ try
{
- prefs = new DisplayPreferences(userId, itemId, client);
- dbContext.DisplayPreferences.Add(prefs);
dbContext.SaveChanges();
}
+ catch (DbUpdateException)
+ {
+ // Another request may have stored the preferences between the lookup and the insert, and the unique index
+ // rejected this one. Return the stored preferences; if there are none, the insert failed for another reason.
+ using var retryContext = _dbContextFactory.CreateDbContext();
+ var stored = FindDisplayPreferences(retryContext, userId, itemId, client);
+ if (stored is null)
+ {
+ throw;
+ }
+
+ return stored;
+ }
return prefs;
}
@@ -52,7 +68,7 @@ public sealed class DisplayPreferencesManager : IDisplayPreferencesManager
if (prefs is null)
{
- prefs = new ItemDisplayPreferences(userId, Guid.Empty, client);
+ prefs = new ItemDisplayPreferences(userId, itemId, client);
dbContext.ItemDisplayPreferences.Add(prefs);
dbContext.SaveChanges();
}
@@ -83,19 +99,35 @@ public sealed class DisplayPreferencesManager : IDisplayPreferencesManager
/// <inheritdoc />
public void SetCustomItemDisplayPreferences(Guid userId, Guid itemId, string client, Dictionary<string, string?> customPreferences)
{
- using var dbContext = _dbContextFactory.CreateDbContext();
- dbContext.CustomItemDisplayPreferences.Where(prefs => prefs.UserId.Equals(userId)
- && prefs.ItemId.Equals(itemId)
- && prefs.Client == client)
- .ExecuteDelete();
-
- foreach (var (key, value) in customPreferences)
+ // Another request can store one of these keys after this one's delete, and the unique index then rejects the
+ // insert. Replacing the set again gives the same result, so the replace is repeated.
+ for (var attempt = 1; ; attempt++)
{
- dbContext.CustomItemDisplayPreferences
- .Add(new CustomItemDisplayPreferences(userId, itemId, client, key, value));
+ using var dbContext = _dbContextFactory.CreateDbContext();
+ using var transaction = dbContext.Database.BeginTransaction();
+ dbContext.CustomItemDisplayPreferences.Where(prefs => prefs.UserId.Equals(userId)
+ && prefs.ItemId.Equals(itemId)
+ && prefs.Client == client)
+ .ExecuteDelete();
+
+ foreach (var (key, value) in customPreferences)
+ {
+ dbContext.CustomItemDisplayPreferences
+ .Add(new CustomItemDisplayPreferences(userId, itemId, client, key, value));
+ }
+
+ try
+ {
+ dbContext.SaveChanges();
+ }
+ catch (DbUpdateException) when (attempt < MaxSaveAttempts)
+ {
+ continue;
+ }
+
+ transaction.Commit();
+ return;
}
-
- dbContext.SaveChanges();
}
/// <inheritdoc/>
@@ -113,4 +145,9 @@ public sealed class DisplayPreferencesManager : IDisplayPreferencesManager
dbContext.ItemDisplayPreferences.Attach(itemDisplayPreferences).State = EntityState.Modified;
dbContext.SaveChanges();
}
+
+ private static DisplayPreferences? FindDisplayPreferences(JellyfinDbContext dbContext, Guid userId, Guid itemId, string client)
+ => dbContext.DisplayPreferences
+ .Include(pref => pref.HomeSections)
+ .FirstOrDefault(pref => pref.UserId.Equals(userId) && pref.Client == client && pref.ItemId.Equals(itemId));
}
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index fea6084267..b15f6b98b2 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -847,14 +847,16 @@ namespace Jellyfin.Server.Implementations.Users
/// <inheritdoc/>
public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy)
{
+ User user;
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
- var user = UserQuery(dbContext)
+ user = await UserQuery(dbContext)
.AsTracking()
- .FirstOrDefault(u => u.Id.Equals(userId))
+ .FirstOrDefaultAsync(u => u.Id.Equals(userId))
+ .ConfigureAwait(false)
?? throw new ArgumentException("No user exists with given Id!");
// The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0"
@@ -919,6 +921,10 @@ namespace Jellyfin.Server.Implementations.Users
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
+
+ var eventArgs = new UserUpdatedEventArgs(user);
+ await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false);
+ OnUserUpdated?.Invoke(this, eventArgs);
}
/// <inheritdoc/>