aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server.Implementations
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Server.Implementations')
-rw-r--r--Jellyfin.Server.Implementations/Activity/ActivityManager.cs10
-rw-r--r--Jellyfin.Server.Implementations/Devices/DeviceManager.cs6
-rw-r--r--Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs56
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs17
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs76
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.cs8
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs7
-rw-r--r--Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs3
-rw-r--r--Jellyfin.Server.Implementations/Item/OrderMapper.cs9
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs27
10 files changed, 167 insertions, 52 deletions
diff --git a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs
index ba24dc3864..f21e94a0fd 100644
--- a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs
+++ b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs
@@ -56,11 +56,11 @@ public class ActivityManager : IActivityManager
var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
- // TODO switch to LeftJoin in .NET 10.
- var entries = from a in dbContext.ActivityLogs
- join u in dbContext.Users on a.UserId equals u.Id into ugj
- from u in ugj.DefaultIfEmpty()
- select new ExpandedActivityLog { ActivityLog = a, Username = u.Username };
+ var entries = dbContext.ActivityLogs.LeftJoin(
+ dbContext.Users,
+ a => a.UserId,
+ u => u.Id,
+ (a, u) => new ExpandedActivityLog { ActivityLog = a, Username = u == null ? null : u.Username });
if (query.HasUserId is not null)
{
diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs
index bcf348f8c6..d0d52a23fb 100644
--- a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs
+++ b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs
@@ -213,8 +213,10 @@ namespace Jellyfin.Server.Implementations.Devices
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
- dbContext.Devices.Remove(device);
- await dbContext.SaveChangesAsync().ConfigureAwait(false);
+ await dbContext.Devices
+ .Where(d => d.Id == device.Id)
+ .ExecuteDeleteAsync()
+ .ConfigureAwait(false);
}
}
diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
index a6dc5458ee..7c10a5dc77 100644
--- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
+++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
@@ -12,6 +12,7 @@ using Jellyfin.Database.Implementations;
using Jellyfin.Server.Implementations.StorageHelpers;
using Jellyfin.Server.Implementations.SystemBackupService;
using MediaBrowser.Controller;
+using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.SystemBackupService;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -33,6 +34,7 @@ public class BackupService : IBackupService
private readonly IServerApplicationPaths _applicationPaths;
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
private readonly IHostApplicationLifetime _hostApplicationLifetime;
+ private readonly ILibraryManager _libraryManager;
private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General)
{
AllowTrailingCommas = true,
@@ -50,13 +52,15 @@ public class BackupService : IBackupService
/// <param name="applicationPaths">The application paths.</param>
/// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param>
/// <param name="applicationLifetime">The SystemManager.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
public BackupService(
ILogger<BackupService> logger,
IDbContextFactory<JellyfinDbContext> dbProvider,
IServerApplicationHost applicationHost,
IServerApplicationPaths applicationPaths,
IJellyfinDatabaseProvider jellyfinDatabaseProvider,
- IHostApplicationLifetime applicationLifetime)
+ IHostApplicationLifetime applicationLifetime,
+ ILibraryManager libraryManager)
{
_logger = logger;
_dbProvider = dbProvider;
@@ -64,6 +68,7 @@ public class BackupService : IBackupService
_applicationPaths = applicationPaths;
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
_hostApplicationLifetime = applicationLifetime;
+ _libraryManager = libraryManager;
}
/// <inheritdoc/>
@@ -263,6 +268,14 @@ public class BackupService : IBackupService
/// <inheritdoc/>
public async Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions)
{
+ // Creating a backup runs a database optimization and reads the entire database under a transaction, both of
+ // which heavily contend with an active library scan and could capture an inconsistent database state.
+ if (_libraryManager.IsScanRunning)
+ {
+ _logger.LogWarning("Cannot create a backup while a library scan is running.");
+ throw new InvalidOperationException("Cannot create a backup while a library scan is running. Please try again once the scan has finished.");
+ }
+
var manifest = new BackupManifest()
{
DateCreated = DateTime.UtcNow,
@@ -346,18 +359,39 @@ public class BackupService : IBackupService
jsonSerializer.WriteStartArray();
var set = entityType.ValueFactory().ConfigureAwait(false);
- await foreach (var item in set.ConfigureAwait(false))
+ var enumerator = set.GetAsyncEnumerator();
+ await using (enumerator)
{
- entities++;
- try
- {
- using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings);
- document.WriteTo(jsonSerializer);
- }
- catch (Exception ex)
+ while (true)
{
- _logger.LogError(ex, "Could not load entity {Entity}", item);
- throw;
+ bool hasNext;
+ try
+ {
+ hasNext = await enumerator.MoveNextAsync();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Could not read next entity of type {Table}, the underlying data appears to be corrupt. Skipping this row and continuing backup; the affected database row should be inspected and fixed manually", entityType.SourceName);
+ continue;
+ }
+
+ if (!hasNext)
+ {
+ break;
+ }
+
+ var item = enumerator.Current;
+ entities++;
+ try
+ {
+ using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings);
+ document.WriteTo(jsonSerializer);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Could not load entity {Entity}", item);
+ throw;
+ }
}
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
index d6ddf8f5c8..a4de9feb05 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
@@ -62,18 +62,21 @@ public sealed partial class BaseItemRepository
private IQueryable<BaseItemEntity> ApplyGroupingFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
- // Collapse duplicates sharing a presentation key (e.g. alternate versions) by picking
- // the min Id per group. Keep the grouped ids as an IQueryable sub-select; materializing
+ // Collapse duplicates sharing a presentation key (e.g. alternate versions), preferring the
+ // primary version (PrimaryVersionId is null) so detail pages and actions target it instead
+ // of an arbitrary alternate. Keep the grouped ids as an IQueryable sub-select; materializing
// to a List would inline one bound parameter per id and hit SQLite's variable cap.
var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter);
if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey)
{
- var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }).Select(e => e.Min(x => x.Id));
+ var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey })
+ .Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id));
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
}
else if (enableGroupByPresentationUniqueKey)
{
- var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey).Select(e => e.Min(x => x.Id));
+ var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey)
+ .Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id));
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
}
else if (filter.GroupBySeriesPresentationUniqueKey)
@@ -444,6 +447,7 @@ public sealed partial class BaseItemRepository
if (filter.IncludeInheritedTags.Length > 0)
{
var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray();
+ var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
var allowedTagItemIds = context.ItemValuesMap
.Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
.Select(f => f.ItemId);
@@ -452,7 +456,10 @@ public sealed partial class BaseItemRepository
allowedTagItemIds.Contains(e.Id)
|| (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value))
|| e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId))
- || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)));
+ || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value))
+
+ // People don't carry the tags of the media they appear in and would never match
+ || e.Type == personTypeName);
}
// Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items.
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index 3357f874d2..f19df6259e 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -513,13 +513,17 @@ public sealed partial class BaseItemRepository
if (filter.IsResumable.HasValue)
{
var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series);
+ var userId = filter.User!.Id;
+ var isResumable = filter.IsResumable.Value;
+ var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
+ // In-progress user data rows; alternate versions track their own progress.
+ var inProgress = context.UserData
+ .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
+
+ IQueryable<Guid>? resumableSeriesIds = null;
if (hasSeries)
{
- var userId = filter.User!.Id;
- var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
- var isResumable = filter.IsResumable.Value;
-
// Aggregate per series in a single GROUP BY pass, instead of three full scans.
var seriesEpisodeStats = context.BaseItems
.AsNoTracking()
@@ -535,26 +539,49 @@ public sealed partial class BaseItemRepository
// A series is resumable if it has an in-progress episode,
// or if it has both played and unplayed episodes (partially watched).
- var resumableSeriesIds = seriesEpisodeStats
+ resumableSeriesIds = seriesEpisodeStats
.Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed))
.Select(s => s.SeriesId);
+ }
- // Non-series items: resumable if PlaybackPositionTicks > 0
- var resumableItemIds = context.UserData
- .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)
- .Select(ud => ud.ItemId);
-
- baseQuery = baseQuery.Where(e =>
- (e.Type == seriesTypeName && resumableSeriesIds.Contains(e.Id) == isResumable)
- || (e.Type != seriesTypeName && resumableItemIds.Contains(e.Id) == isResumable));
+ if (isResumable)
+ {
+ // Resume queries surface the version that was actually played, which may be an alternate.
+ // Match each version on its own progress rather than coalescing onto the primary.
+ var inProgressIds = inProgress.Select(ud => ud.ItemId);
+
+ baseQuery = hasSeries
+ ? baseQuery.Where(e =>
+ (e.Type == seriesTypeName && resumableSeriesIds!.Contains(e.Id))
+ || (e.Type != seriesTypeName && inProgressIds.Contains(e.Id)))
+ : baseQuery.Where(e => inProgressIds.Contains(e.Id));
+
+ // When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker.
+ // Only in-progress siblings can eliminate a candidate: a version without progress has a NULL max LastPlayedDate,
+ // which is never greater and never ties. Restricting the sibling scan to the in-progress set keeps this bounded by
+ // the user's Continue Watching count instead of forcing a full BaseItems scan (COALESCE keys are non-indexable) per row.
+ baseQuery = baseQuery.Where(e => e.Type == seriesTypeName || !context.BaseItems
+ .Where(s => s.Id != e.Id
+ && inProgressIds.Contains(s.Id)
+ && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
+ .Any(s =>
+ inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ && s.Id.CompareTo(e.Id) < 0)));
}
else
{
- var resumableItemIds = context.UserData
- .Where(ud => ud.UserId == filter.User!.Id && ud.PlaybackPositionTicks > 0)
- .Select(ud => ud.ItemId);
- var isResumable = filter.IsResumable.Value;
- baseQuery = baseQuery.Where(e => resumableItemIds.Contains(e.Id) == isResumable);
+ // Not-resumable queries operate on primaries only.
+ var resumableMovieIds = inProgress
+ .Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id);
+
+ baseQuery = hasSeries
+ ? baseQuery.Where(e =>
+ (e.Type == seriesTypeName && !resumableSeriesIds!.Contains(e.Id))
+ || (e.Type != seriesTypeName && !resumableMovieIds.Contains(e.Id)))
+ : baseQuery.Where(e => !resumableMovieIds.Contains(e.Id));
}
}
@@ -741,10 +768,13 @@ public sealed partial class BaseItemRepository
}
else if (filter.OwnerIds.Length == 0 && filter.ExtraTypes.Length == 0 && !filter.IncludeOwnedItems)
{
- // Exclude alternate versions and owned non-extra items from general queries.
- // Alternate versions have PrimaryVersionId set (pointing to their primary).
+ // Exclude owned non-extra items from general queries.
// Extras (trailers, etc.) have OwnerId set but also have ExtraType set - keep those.
- baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
+ // 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));
}
if (filter.OwnerIds.Length > 0)
@@ -1059,6 +1089,7 @@ public sealed partial class BaseItemRepository
{
var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray();
var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist;
+ var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
var allowedTagItemIds = context.ItemValuesMap
.Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
.Select(f => f.ItemId);
@@ -1069,6 +1100,9 @@ public sealed partial class BaseItemRepository
|| e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId))
|| (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value))
+ // People don't carry the tags of the media they appear in and would never match
+ || e.Type == personTypeName
+
// A playlist should be accessible to its owner regardless of allowed tags
|| (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")));
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
index 94dedaeba8..57041276b7 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
@@ -167,6 +167,14 @@ public sealed partial class BaseItemRepository
return false;
}
+ // Resume queries surface the actually-played version (which may be an alternate sharing the
+ // primary's presentation key). The resumable filter already keeps one version per group, so
+ // presentation-key grouping must not collapse the surfaced version back onto the primary.
+ if (query.IsResumable == true)
+ {
+ return false;
+ }
+
if (query.GroupBySeriesPresentationUniqueKey)
{
return false;
diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
index 7c0cfe7c15..b10f7c527e 100644
--- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
@@ -65,8 +65,13 @@ public class ItemPersistenceService : IItemPersistenceService
descendantIds.Add(id);
}
+ // 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 && descendantIds.Contains(e.OwnerId.Value))
+ .Where(e => e.OwnerId.HasValue)
+ .WhereOneOrMany(ownerIds, e => e.OwnerId!.Value)
.Select(e => e.Id)
.ToArray();
diff --git a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
index 7fa33c8639..a25629132b 100644
--- a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
@@ -172,8 +172,7 @@ public class MediaStreamRepository : IMediaStreamRepository
if (!string.IsNullOrEmpty(dto.Language))
{
- var culture = _localization.FindLanguageInfo(dto.Language);
- dto.LocalizedLanguage = culture?.DisplayName;
+ dto.LocalizedLanguage = _localization.GetLanguageDisplayName(dto.Language);
}
if (dto.Type is MediaStreamType.Audio)
diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
index d327b218a9..aac85d0131 100644
--- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs
+++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
@@ -34,7 +34,14 @@ public static class OrderMapper
(ItemSortBy.AirTime, _) => e => e.SortName,
(ItemSortBy.Runtime, _) => e => e.RunTimeTicks,
(ItemSortBy.Random, _) => e => EF.Functions.Random(),
- (ItemSortBy.DatePlayed, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.LastPlayedDate,
+ (ItemSortBy.DatePlayed, not null) => e =>
+ jellyfinDbContext.UserData
+ .Where(w => w.UserId == query.User.Id && (w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id))
+ .Max(f => f.LastPlayedDate),
+ (ItemSortBy.DatePlayed, null) => e =>
+ jellyfinDbContext.UserData
+ .Where(w => w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id)
+ .Max(f => f.LastPlayedDate),
(ItemSortBy.PlayCount, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.PlayCount,
(ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).Select(f => (bool?)f.IsFavorite).FirstOrDefault() ?? false,
(ItemSortBy.IsFolder, _) => e => e.IsFolder,
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index 9be2eac4a1..81408d9aa8 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -170,7 +170,7 @@ namespace Jellyfin.Server.Implementations.Users
{
ThrowIfInvalidUsername(newName);
- if (oldName.Equals(newName, StringComparison.OrdinalIgnoreCase))
+ if (oldName.Equals(newName, StringComparison.Ordinal))
{
throw new ArgumentException("The new and old names must be different.");
}
@@ -616,6 +616,12 @@ namespace Jellyfin.Server.Implementations.Users
.SetProperty(f => f.LastActivityDate, date)
.SetProperty(f => f.LastLoginDate, date))
.ConfigureAwait(false);
+
+ // ExecuteUpdateAsync bypasses the change tracker, so keep the
+ // returned entity in sync. Otherwise SessionManager.LogSessionActivity
+ // saves this (stale) entity in full and reverts LastLoginDate.
+ user.LastActivityDate = date;
+ user.LastLoginDate = date;
}
await dbContext.Users
@@ -631,6 +637,7 @@ namespace Jellyfin.Server.Implementations.Users
if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
{
user.SetPermission(PermissionKind.IsDisabled, true);
+ dbContext.Update(user);
await dbContext.SaveChangesAsync()
.ConfigureAwait(false);
await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
@@ -882,8 +889,20 @@ namespace Jellyfin.Server.Implementations.Users
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
- dbContext.Remove(user.ProfileImage);
- await dbContext.SaveChangesAsync().ConfigureAwait(false);
+ // Remove the tracked profile image loaded from the database instead of the
+ // detached instance on the passed in user. That instance can carry a stale,
+ // never-persisted (temporary) key, which makes EF Core throw when it is marked
+ // for deletion, leaving the profile image impossible to clear or replace.
+ var dbUser = await UserQuery(dbContext)
+ .AsTracking()
+ .FirstOrDefaultAsync(u => u.Id == user.Id)
+ .ConfigureAwait(false);
+ if (dbUser?.ProfileImage is not null)
+ {
+ dbContext.Remove(dbUser.ProfileImage);
+ dbUser.ProfileImage = null;
+ await dbContext.SaveChangesAsync().ConfigureAwait(false);
+ }
}
user.ProfileImage = null;
@@ -892,7 +911,7 @@ namespace Jellyfin.Server.Implementations.Users
internal static void ThrowIfInvalidUsername(string name)
{
- if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name))
+ if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name) && !string.Equals(name, ".", StringComparison.Ordinal) && !string.Equals(name, "..", StringComparison.Ordinal))
{
return;
}