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.cs15
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs11
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs94
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.cs8
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs11
-rw-r--r--Jellyfin.Server.Implementations/Item/OrderMapper.cs9
-rw-r--r--Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs175
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs229
10 files changed, 476 insertions, 92 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..a534fa5fa0 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,
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
index d6ddf8f5c8..decd45ae2c 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)
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index f33a65a703..1198f99473 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));
}
}
@@ -586,8 +613,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)
@@ -742,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)
@@ -953,24 +982,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/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 ffa5cff1f2..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();
@@ -557,9 +562,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/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/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 37c4106496..41648268a9 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -51,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.
@@ -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.");
}
@@ -214,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);
+ }
}
}
@@ -453,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)
@@ -466,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;
@@ -479,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
}
}
}
@@ -494,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);
}
}
@@ -542,16 +609,49 @@ 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);
+
+ // 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;
}
- 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);
+ dbContext.Update(user);
+ 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,
@@ -789,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;
@@ -926,32 +1038,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);
@@ -977,5 +1063,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.");
+ }
+ }
+ }
+ }
}
}