diff options
| author | Daniel Țuțuianu <tutuianu_daniel@yahoo.com> | 2026-06-17 06:16:42 +0300 |
|---|---|---|
| committer | Daniel Țuțuianu <tutuianu_daniel@yahoo.com> | 2026-06-17 06:16:42 +0300 |
| commit | 1ea525a4083dbdc929605eb0eb5c6add93bc8392 (patch) | |
| tree | 97056e3e9b8e06ae825199214ec3f9d34b53e4c8 /Jellyfin.Server.Implementations | |
| parent | 372c1681d8272c6fa8f120a132bc40351067fb10 (diff) | |
| parent | 3307406ac8d7aa62184f99946f69a1cbf92a060b (diff) | |
Merge branch 'master' into fix/livetv-channel-icon-refresh
Resolve GuideManager conflict by keeping LiveTvChannelImageHelper so
channel icons re-fetch on every guide refresh, including when the URL
is unchanged.
Diffstat (limited to 'Jellyfin.Server.Implementations')
7 files changed, 423 insertions, 81 deletions
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs index e4fd3204e1..c5b5fbf6d8 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -170,12 +170,22 @@ public sealed partial class BaseItemRepository }; // Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) by picking - // the lowest Id per group. Keep as an IQueryable sub-select so paging is applied AFTER + // the lowest Id per group. For MusicArtist, prefer the entity from a library the user + // can actually access,since the same artist can have a folder in multiple libraries. + // Keep as an IQueryable sub-select so paging is applied AFTER // ApplyOrder runs the caller's actual sort. var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter); - var representativeIds = masterQuery - .GroupBy(e => e.PresentationUniqueKey) - .Select(g => g.Min(e => e.Id)); + var isMusicArtist = returnType == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; + var representativeIds = isMusicArtist + ? masterQuery + .GroupBy(e => e.PresentationUniqueKey) + .Select(g => g + .OrderBy(e => filter.TopParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1) + .ThenBy(e => e.Id) + .First().Id) + : masterQuery + .GroupBy(e => e.PresentationUniqueKey) + .Select(g => g.Min(e => e.Id)); var result = new QueryResult<(BaseItemDto, ItemCounts?)>(); if (filter.EnableTotalRecordCount) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index f33a65a703..3357f874d2 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -586,8 +586,7 @@ public sealed partial class BaseItemRepository if (filter.AlbumIds.Length > 0) { - var subQuery = context.BaseItems.WhereOneOrMany(filter.AlbumIds, f => f.Id); - baseQuery = baseQuery.Where(e => subQuery.Any(f => f.Name == e.Album)); + baseQuery = baseQuery.Where(e => e.ParentId.HasValue && filter.AlbumIds.Contains(e.ParentId.Value)); } if (filter.ExcludeArtistIds.Length > 0) @@ -953,24 +952,17 @@ public sealed partial class BaseItemRepository if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0) { - var exclude = filter.ExcludeProviderIds.Select(e => $"{e.Key}:{e.Value}").ToArray(); - baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.All(f => !exclude.Contains(f))); + baseQuery = baseQuery.WhereExcludeProviderIds(filter.ExcludeProviderIds); } if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0) { - // Allow setting a null or empty value to get all items that have the specified provider set. - var includeAny = filter.HasAnyProviderId.Where(e => string.IsNullOrEmpty(e.Value)).Select(e => e.Key).ToArray(); - if (includeAny.Length > 0) - { - baseQuery = baseQuery.Where(e => e.Provider!.Any(f => includeAny.Contains(f.ProviderId))); - } + baseQuery = baseQuery.WhereHasAnyProviderId(filter.HasAnyProviderId); + } - var includeSelected = filter.HasAnyProviderId.Where(e => !string.IsNullOrEmpty(e.Value)).Select(e => $"{e.Key}:{e.Value}").ToArray(); - if (includeSelected.Length > 0) - { - baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeSelected.Contains(f))); - } + if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0) + { + baseQuery = baseQuery.WhereHasAnyProviderIds(filter.HasAnyProviderIds); } if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0) diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs index ffa5cff1f2..7c0cfe7c15 100644 --- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs @@ -557,9 +557,11 @@ public class ItemPersistenceService : IItemPersistenceService } } + // Deduplicate; local (file-based) relationships take priority over linked (user-merged) + // ones, matching the LinkedChildren migration. newLinkedChildren = newLinkedChildren .GroupBy(c => c.ChildId) - .Select(g => g.Last()) + .Select(g => g.OrderBy(c => c.Type == LinkedChildType.LocalAlternateVersion ? 0 : 1).First()) .ToList(); var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList(); diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs index 9e11b6be62..5e5ce320a5 100644 --- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs +++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs @@ -91,14 +91,25 @@ public class LinkedChildrenService : ILinkedChildrenService } /// <inheritdoc/> - public IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId) + public IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId, BaseItemKind? parentType = null) { using var context = _dbProvider.CreateDbContext(); - return context.LinkedChildren - .Where(lc => lc.ChildId == childId && lc.ChildType == DbLinkedChildType.Manual) - .Select(lc => lc.ParentId) - .Distinct() - .ToList(); + + var query = context.LinkedChildren + .Where(lc => lc.ChildId == childId && lc.ChildType == DbLinkedChildType.Manual); + + if (parentType.HasValue) + { + var parentTypeName = _itemTypeLookup.BaseItemKindNames[parentType.Value]; + query = query.Join( + context.BaseItems + .Where(item => item.Type == parentTypeName), + lc => lc.ParentId, + item => item.Id, + (lc, _) => lc); + } + + return query.Select(lc => lc.ParentId).Distinct().ToList(); } /// <inheritdoc/> diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index b612112d49..eb87b525fe 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -165,6 +165,42 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I transaction.Commit(); } + /// <inheritdoc/> + public IReadOnlyDictionary<Guid, IReadOnlyList<string>> GetPeopleNamesByItems(IReadOnlyList<Guid> itemIds, IReadOnlyList<string> personTypes) + { + using var context = _dbProvider.CreateDbContext(); + var query = context.PeopleBaseItemMap + .AsNoTracking() + .Where(m => itemIds.Contains(m.ItemId)); + + if (personTypes.Count > 0) + { + query = query.Where(m => personTypes.Contains(m.People.PersonType)); + } + + var rows = query + .OrderBy(m => m.ListOrder) + .Select(m => new { m.ItemId, m.People.Name }) + .ToList(); + + var result = new Dictionary<Guid, IReadOnlyList<string>>(); + foreach (var group in rows.GroupBy(r => r.ItemId)) + { + var names = group + .Select(r => r.Name) + .Where(name => !string.IsNullOrEmpty(name)) + .Distinct() + .ToArray(); + + if (names.Length > 0) + { + result[group.Key] = names; + } + } + + return result; + } + private PersonInfo Map(People people) { var mapping = people.BaseItems?.FirstOrDefault(); @@ -239,7 +275,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I if (filter.MaxListOrder.HasValue && !filter.ItemId.IsEmpty()) { - query = query.Where(e => e.BaseItems!.Where(w => w.ItemId == filter.ItemId).OrderBy(w => w.ListOrder).First().ListOrder <= filter.MaxListOrder.Value); + query = query.Where(e => e.BaseItems!.Any(w => w.ItemId == filter.ItemId && w.ListOrder <= filter.MaxListOrder.Value)); } if (!string.IsNullOrWhiteSpace(filter.NameContains)) diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs index 0791e04e85..58b9f7f822 100644 --- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs +++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Text; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; @@ -28,7 +29,7 @@ namespace Jellyfin.Server.Implementations.Trickplay; /// <summary> /// ITrickplayManager implementation. /// </summary> -public class TrickplayManager : ITrickplayManager +public partial class TrickplayManager : ITrickplayManager { private readonly ILogger<TrickplayManager> _logger; private readonly IMediaEncoder _mediaEncoder; @@ -135,6 +136,147 @@ public class TrickplayManager : ITrickplayManager } } + private async Task DiscoverExistingTrickplayAsync(Video video, bool saveWithMedia, CancellationToken cancellationToken) + { + var options = _config.Configuration.TrickplayOptions; + var existing = await GetTrickplayResolutions(video.Id).ConfigureAwait(false); + + // Remove DB rows whose on-disk folder no longer exists in either possible location. + // Checking both locations avoids dropping rows mid-`SaveTrickplayWithMedia` migration. + var orphanedWidths = new List<int>(); + foreach (var (width, info) in existing) + { + cancellationToken.ThrowIfCancellationRequested(); + var localDir = GetTrickplayDirectory(video, info.TileWidth, info.TileHeight, info.Width, false); + var mediaDir = GetTrickplayDirectory(video, info.TileWidth, info.TileHeight, info.Width, true); + if (!HasTrickplayTiles(localDir) && !HasTrickplayTiles(mediaDir)) + { + orphanedWidths.Add(width); + } + } + + if (orphanedWidths.Count > 0) + { + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + await dbContext.TrickplayInfos + .Where(i => i.ItemId.Equals(video.Id) && orphanedWidths.Contains(i.Width)) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + } + + foreach (var width in orphanedWidths) + { + _logger.LogInformation("Removed orphaned trickplay DB entry width={Width} for {Path}", width, video.Path); + existing.Remove(width); + } + } + + var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia); + if (!Directory.Exists(trickplayDirectory)) + { + return; + } + + foreach (var subdir in new DirectoryInfo(trickplayDirectory).EnumerateDirectories()) + { + cancellationToken.ThrowIfCancellationRequested(); + + var match = TrickplaySubdirRegex().Match(subdir.Name); + if (!match.Success) + { + continue; + } + + var width = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + var tileWidth = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture); + var tileHeight = int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture); + + if (existing.ContainsKey(width)) + { + continue; + } + + var tiles = subdir.GetFiles("*.jpg") + .OrderBy(t => t.Name, StringComparer.Ordinal) + .ToArray(); + if (tiles.Length == 0) + { + continue; + } + + // The encoder pads the last tile to a full TileWidth*TileHeight grid, so the real + // thumbnail count cannot be read from tile dimensions. Instead, bound the count from + // the tile count and per-tile capacity, then pick an interval consistent with the + // video runtime - snapping to the server's configured interval when it fits. + var thumbsPerTile = tileWidth * tileHeight; + var maxThumbs = tiles.Length * thumbsPerTile; + var minThumbs = tiles.Length > 1 ? ((tiles.Length - 1) * thumbsPerTile) + 1 : 1; + + int interval; + int thumbnailCount; + if (video.RunTimeTicks is long ticks) + { + var runtimeMs = ticks / TimeSpan.TicksPerMillisecond; + var minInterval = Math.Max(1000L, (long)Math.Ceiling(runtimeMs / (double)maxThumbs)); + var maxInterval = Math.Max(minInterval, (long)Math.Floor(runtimeMs / (double)minThumbs)); + + if (options.Interval >= minInterval && options.Interval <= maxInterval) + { + interval = options.Interval; + } + else + { + var midpoint = (minInterval + maxInterval) / 2.0; + var snapped = (long)Math.Round(midpoint / 1000d) * 1000L; + interval = (int)Math.Clamp(snapped, minInterval, maxInterval); + } + + thumbnailCount = Math.Clamp( + (int)Math.Round(runtimeMs / (double)interval), + minThumbs, + maxThumbs); + } + else + { + interval = Math.Max(1000, options.Interval); + thumbnailCount = maxThumbs; + } + + var firstSize = _imageEncoder.GetImageSize(tiles[0].FullName); + var thumbPxH = Math.Max(1, (int)Math.Ceiling((double)firstSize.Height / tileHeight)); + + var info = new TrickplayInfo + { + ItemId = video.Id, + Width = width, + Interval = interval, + TileWidth = tileWidth, + TileHeight = tileHeight, + ThumbnailCount = thumbnailCount, + Height = thumbPxH, + Bandwidth = 0, + }; + + foreach (var tile in tiles) + { + var bitrate = (int)Math.Ceiling((decimal)tile.Length * 8 / tileWidth / tileHeight / (interval / 1000m)); + info.Bandwidth = Math.Max(info.Bandwidth, bitrate); + } + + await SaveTrickplayInfo(info).ConfigureAwait(false); + _logger.LogInformation( + "Discovered existing trickplay {Width} - {TileWidth}x{TileHeight} ({ThumbnailCount} thumbnails, {Interval}ms interval) for {Path}", + width, + tileWidth, + tileHeight, + thumbnailCount, + interval, + video.Path); + } + } + /// <inheritdoc /> public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationToken cancellationToken) { @@ -144,11 +286,27 @@ public class TrickplayManager : ITrickplayManager return; } + var saveWithMedia = libraryOptions.SaveTrickplayWithMedia; + + // Catalog any existing trickplay folders on disk before any prune/generate. This picks up + // user-placed files even when their (width, tile dims) don't match the server's configured values. + if (!replace) + { + await DiscoverExistingTrickplayAsync(video, saveWithMedia, cancellationToken).ConfigureAwait(false); + } + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - var saveWithMedia = libraryOptions.SaveTrickplayWithMedia; var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia); + + // When extraction is disabled and files live next to media, treat them as user-managed: + // discovery above already catalogued whatever is on disk, leave it alone. + if (!libraryOptions.EnableTrickplayImageExtraction && !replace && saveWithMedia) + { + return; + } + if (!libraryOptions.EnableTrickplayImageExtraction || replace) { // Prune existing data @@ -688,6 +846,19 @@ public class TrickplayManager : ITrickplayManager return Path.Combine(path, subdirectory); } + [GeneratedRegex(@"^(\d+) - (\d+)x(\d+)$")] + private static partial Regex TrickplaySubdirRegex(); + + private static bool HasTrickplayTiles(string directory) + { + if (!Directory.Exists(directory)) + { + return false; + } + + return new DirectoryInfo(directory).EnumerateFiles("*.jpg").Any(); + } + private async Task<bool> HasTrickplayResolutionAsync(Guid itemId, int width) { var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 8c0cbbd448..9be2eac4a1 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -1,4 +1,3 @@ -#pragma warning disable CA1307 #pragma warning disable RS0030 // Do not use banned APIs using System; @@ -52,7 +51,7 @@ namespace Jellyfin.Server.Implementations.Users private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider; private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly AsyncKeyedLocker<Guid> _userLock = new(); + private readonly LockHelper _userLock = new(); /// <summary> /// Initializes a new instance of the <see cref="UserManager"/> class. @@ -161,12 +160,8 @@ namespace Jellyfin.Server.Implementations.Users using var dbContext = _dbProvider.CreateDbContext(); #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons -#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture -#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings return UserQuery(dbContext) - .FirstOrDefault(u => u.Username.ToUpper() == name.ToUpper()); -#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings -#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture + .FirstOrDefault(u => u.NormalizedUsername == name.ToUpperInvariant()); #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons } @@ -187,10 +182,8 @@ namespace Jellyfin.Server.Implementations.Users await using (dbContext.ConfigureAwait(false)) { #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons -#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture -#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings if (await dbContext.Users - .AnyAsync(u => u.Username.ToUpper() == newName.ToUpper() && u.Id != userId) + .AnyAsync(u => u.NormalizedUsername == newName.ToUpperInvariant() && u.Id != userId) .ConfigureAwait(false)) { throw new ArgumentException(string.Format( @@ -198,8 +191,6 @@ namespace Jellyfin.Server.Implementations.Users "A user with the name '{0}' already exists.", newName)); } -#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings -#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons user = await UserQuery(dbContext) @@ -208,6 +199,7 @@ namespace Jellyfin.Server.Implementations.Users .ConfigureAwait(false) ?? throw new ResourceNotFoundException(nameof(userId)); user.Username = newName; + user.NormalizedUsername = newName.ToUpperInvariant(); await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); } } @@ -222,7 +214,58 @@ namespace Jellyfin.Server.Implementations.Users { using (await _userLock.LockAsync(user.Id).ConfigureAwait(false)) { - await UpdateUserInternalAsync(user).ConfigureAwait(false); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + // TODO: this is a bit of a hack. Because the user entity can be created in another context, it is maybe tracked elsewhere and navigation properties do not easily move between context. Solution is to use proper DTOs instead. + var dbUser = await UserQuery(dbContext) + .AsTracking() + .FirstOrDefaultAsync(u => u.Id == user.Id) + .ConfigureAwait(false) + ?? throw new ResourceNotFoundException(nameof(user.Id)); + + dbContext.Entry(dbUser).CurrentValues.SetValues(user); + dbUser.Permissions.Clear(); + foreach (var permission in user.Permissions) + { + dbUser.Permissions.Add(new Permission(permission.Kind, permission.Value)); + } + + dbUser.Preferences.Clear(); + foreach (var preference in user.Preferences) + { + dbUser.Preferences.Add(new Preference(preference.Kind, preference.Value)); + } + + dbUser.AccessSchedules.Clear(); + foreach (var accessSchedule in user.AccessSchedules) + { + dbUser.AccessSchedules.Add(new AccessSchedule(accessSchedule.DayOfWeek, accessSchedule.StartHour, accessSchedule.EndHour, dbUser.Id)); + } + + if (user.ProfileImage is null) + { + if (dbUser.ProfileImage is not null) + { + dbContext.Remove(dbUser.ProfileImage); + dbUser.ProfileImage = null; + } + } + else if (dbUser.ProfileImage is null) + { + dbUser.ProfileImage = new Jellyfin.Database.Implementations.Entities.ImageInfo(user.ProfileImage.Path) + { + LastModified = user.ProfileImage.LastModified + }; + } + else + { + dbUser.ProfileImage.Path = user.ProfileImage.Path; + dbUser.ProfileImage.LastModified = user.ProfileImage.LastModified; + } + + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } } @@ -257,10 +300,8 @@ namespace Jellyfin.Server.Implementations.Users await using (dbContext.ConfigureAwait(false)) { #pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons -#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture -#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings if (await dbContext.Users - .AnyAsync(u => u.Username.ToUpper() == name.ToUpper()) + .AnyAsync(u => u.NormalizedUsername == name.ToUpperInvariant()) .ConfigureAwait(false)) { throw new ArgumentException(string.Format( @@ -268,8 +309,6 @@ namespace Jellyfin.Server.Implementations.Users "A user with the name '{0}' already exists.", name)); } -#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings -#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture #pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false); @@ -465,12 +504,14 @@ namespace Jellyfin.Server.Implementations.Users var user = GetUserByName(username); using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false)) { + using var dbContext = _dbProvider.CreateDbContext(); + // Reload the user now that we hold the lock so the RowVersion is current. // GetUserByName uses AsNoTracking and the snapshot may be stale if another // write (e.g. a concurrent login) incremented RowVersion after our initial load. if (user is not null) { - user = GetUserById(user.Id) ?? user; + user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false) ?? user; } var authResult = await AuthenticateLocalUser(username, password, user) @@ -478,6 +519,13 @@ namespace Jellyfin.Server.Implementations.Users var authenticationProvider = authResult.AuthenticationProvider; success = authResult.Success; + if (success && user is not null) + { + // refresh the user if the auth provider might have updated it in the auth method. + // this is a hack, this needs removal once the LDAP plugin uses the correct interface to get the user we hand in here and update that one instead. + user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false); + } + if (user is null) { string updatedUsername = authResult.Username; @@ -491,11 +539,16 @@ namespace Jellyfin.Server.Implementations.Users // Search the database for the user again // the authentication provider might have created it - user = GetUserByName(username); +#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons + user = await UserQuery(dbContext) + .FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false); if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null) { await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false); + user = await UserQuery(dbContext) + .FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false); +#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons } } } @@ -506,8 +559,10 @@ namespace Jellyfin.Server.Implementations.Users if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase)) { - user.AuthenticationProviderId = providerId; - await UpdateUserInternalAsync(user).ConfigureAwait(false); + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.AuthenticationProviderId, providerId)) + .ConfigureAwait(false); } } @@ -554,16 +609,42 @@ namespace Jellyfin.Server.Implementations.Users { if (isUserSession) { - user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow; + var date = DateTime.UtcNow; + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e + .SetProperty(f => f.LastActivityDate, date) + .SetProperty(f => f.LastLoginDate, date)) + .ConfigureAwait(false); } - user.InvalidLoginAttemptCount = 0; - await UpdateUserInternalAsync(user).ConfigureAwait(false); + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, 0)) + .ConfigureAwait(false); _logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username); } else { - await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false); + user.InvalidLoginAttemptCount++; + int? maxInvalidLogins = user.LoginAttemptsBeforeLockout; + if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins) + { + user.SetPermission(PermissionKind.IsDisabled, true); + await dbContext.SaveChangesAsync() + .ConfigureAwait(false); + await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false); + _logger.LogWarning( + "Disabling user {Username} due to {Attempts} unsuccessful login attempts.", + user.Username, + user.InvalidLoginAttemptCount); + } + + await dbContext.Users + .Where(e => e.Id == user.Id) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, f => f.InvalidLoginAttemptCount + 1)) + .ConfigureAwait(false); + _logger.LogInformation( "Authentication request for {UserName} has been denied (IP: {IP}).", user.Username, @@ -938,32 +1019,6 @@ namespace Jellyfin.Server.Implementations.Users } } - private async Task IncrementInvalidLoginAttemptCount(User user) - { - user.InvalidLoginAttemptCount++; - int? maxInvalidLogins = user.LoginAttemptsBeforeLockout; - if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins) - { - user.SetPermission(PermissionKind.IsDisabled, true); - await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false); - _logger.LogWarning( - "Disabling user {Username} due to {Attempts} unsuccessful login attempts.", - user.Username, - user.InvalidLoginAttemptCount); - } - - await UpdateUserInternalAsync(user).ConfigureAwait(false); - } - - private async Task UpdateUserInternalAsync(User user) - { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { - await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); - } - } - private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user) { dbContext.Users.Attach(user); @@ -989,5 +1044,70 @@ namespace Jellyfin.Server.Implementations.Users _userLock.Dispose(); } } + + internal sealed class LockHelper : IDisposable + { + private readonly AsyncKeyedLocker<Guid> _userLock = new(); + + private bool _disposed; + + public static AsyncLocal<int> IsNestedLock { get; set; } = new(); + + public bool ShouldLock() + { + return IsNestedLock.Value == 0; + } + + public ValueTask<IDisposable> LockAsync(Guid key) + { + ThrowIfDisposed(); + var isNested = LockHelper.IsNestedLock.Value != 0; + LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value + 1; + if (isNested) + { + return new ValueTask<IDisposable>(new LockHandle { Parent = null }); + } + + return AcquireLockAsync(key); + } + + private async ValueTask<IDisposable> AcquireLockAsync(Guid key) + { + var lockHandle = await _userLock.LockAsync(key, true).ConfigureAwait(false); + return new LockHandle { Parent = lockHandle }; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _userLock.Dispose(); + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + private sealed class LockHandle : IDisposable + { + public required IDisposable? Parent { get; init; } + + public void Dispose() + { + Parent?.Dispose(); + LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value - 1; + + if (LockHelper.IsNestedLock.Value < 0) + { + throw new InvalidOperationException("Mismatched locking detected. Threads internal NestedLock is less then 0 which should not be possible."); + } + } + } + } } } |
