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/Events/Consumers/Session/PlaybackStopLogger.cs6
-rw-r--r--Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs13
-rw-r--r--Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs56
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemMapper.cs19
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs155
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs263
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs162
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs515
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.cs33
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemCountService.cs175
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs257
-rw-r--r--Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs68
-rw-r--r--Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs3
-rw-r--r--Jellyfin.Server.Implementations/Item/NextUpService.cs25
-rw-r--r--Jellyfin.Server.Implementations/Item/OrderMapper.cs22
-rw-r--r--Jellyfin.Server.Implementations/Item/PeopleRepository.cs248
-rw-r--r--Jellyfin.Server.Implementations/Security/AuthorizationContext.cs2
-rw-r--r--Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs5
-rw-r--r--Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs175
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs296
22 files changed, 1853 insertions, 661 deletions
diff --git a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs
index fe987b9d86..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/Events/Consumers/Session/PlaybackStopLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs
index 09d68e4451..a88904c727 100644
--- a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs
@@ -75,9 +75,9 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session
eventArgs.DeviceName),
notificationType,
user.Id)
- {
- ItemId = eventArgs.Item?.Id.ToString("N", CultureInfo.InvariantCulture),
- })
+ {
+ ItemId = eventArgs.Item?.Id.ToString("N", CultureInfo.InvariantCulture),
+ })
.ConfigureAwait(false);
}
diff --git a/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs
index d70ac672f2..0f166fc6e0 100644
--- a/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs
+++ b/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs
@@ -40,6 +40,19 @@ public static class ExpressionExtensions
}
/// <summary>
+ /// Negates a predicate.
+ /// </summary>
+ /// <typeparam name="T">The predicate parameter type.</typeparam>
+ /// <param name="predicate">The predicate expression to negate.</param>
+ /// <returns>A new expression representing the negation of the input predicate.</returns>
+ public static Expression<Func<T, bool>> Not<T>(this Expression<Func<T, bool>> predicate)
+ {
+ ArgumentNullException.ThrowIfNull(predicate);
+
+ return Expression.Lambda<Func<T, bool>>(Expression.Not(predicate.Body), predicate.Parameters);
+ }
+
+ /// <summary>
/// Combines two predicates into a single predicate using a logical AND operation.
/// </summary>
/// <typeparam name="T">The predicate parameter type.</typeparam>
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/BaseItemMapper.cs b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
index 736388e9eb..c2cb644c59 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
@@ -26,7 +26,7 @@ namespace Jellyfin.Server.Implementations.Item;
/// <summary>
/// Handles mapping between BaseItemEntity (database) and BaseItemDto (domain) objects.
/// </summary>
-internal static class BaseItemMapper
+public static class BaseItemMapper
{
/// <summary>
/// This holds all the types in the running assemblies
@@ -134,6 +134,21 @@ internal static class BaseItemMapper
if (dto is Video video)
{
video.PrimaryVersionId = entity.PrimaryVersionId;
+
+ // The LinkedChildren table is the source of truth for version links
+ if (entity.LinkedChildEntities is not null)
+ {
+ video.LinkedAlternateVersions = entity.LinkedChildEntities
+ // LocalAlternateVersion links belong to Video.LocalAlternateVersions, not here
+ .Where(e => e.ChildType == Database.Implementations.Entities.LinkedChildType.LinkedAlternateVersion)
+ .OrderBy(e => e.SortOrder)
+ .Select(e => new LinkedChild
+ {
+ ItemId = e.ChildId,
+ Type = (MediaBrowser.Controller.Entities.LinkedChildType)e.ChildType
+ })
+ .ToArray();
+ }
}
if (dto is IHasSeries hasSeriesName)
@@ -183,7 +198,7 @@ internal static class BaseItemMapper
if (dto is Folder folder)
{
folder.DateLastMediaAdded = entity.DateLastMediaAdded ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
- if (entity.LinkedChildEntities is not null && entity.LinkedChildEntities.Count > 0)
+ if (entity.LinkedChildEntities is not null)
{
folder.LinkedChildren = entity.LinkedChildEntities
.OrderBy(e => e.SortOrder)
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
index e4fd3204e1..5a41619390 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
@@ -4,9 +4,11 @@ using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.EntityFrameworkCore;
using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
@@ -81,6 +83,40 @@ public sealed partial class BaseItemRepository
_itemTypeLookup.MusicGenreTypes);
}
+ /// <inheritdoc />
+ public IReadOnlyList<string> GetMediaStreamLanguages(InternalItemsQuery filter, MediaStreamType mediaStreamType)
+ {
+ ArgumentNullException.ThrowIfNull(filter);
+
+ using var context = _dbProvider.CreateDbContext();
+
+ return TranslateQuery(
+ context.BaseItems.Include(e => e.MediaStreams).Where(e => e.Id != EF.Constant(PlaceholderId)),
+ context,
+ new InternalItemsQuery(filter.User)
+ {
+ IncludeOwnedItems = filter.IncludeOwnedItems,
+ ExcludeItemTypes = filter.ExcludeItemTypes,
+ IncludeItemTypes = filter.IncludeItemTypes,
+ MediaTypes = filter.MediaTypes,
+ AncestorIds = filter.AncestorIds,
+ ItemIds = filter.ItemIds,
+ TopParentIds = filter.TopParentIds,
+ ParentId = filter.ParentId,
+ IsAiring = filter.IsAiring,
+ IsMovie = filter.IsMovie,
+ IsSports = filter.IsSports,
+ IsKids = filter.IsKids,
+ IsNews = filter.IsNews,
+ IsSeries = filter.IsSeries
+ })
+ .SelectMany(e => e.MediaStreams!)
+ .Where(e => e.StreamType == (MediaStreamTypeEntity)mediaStreamType)
+ .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined
+ .Distinct()
+ .ToArray();
+ }
+
private string[] GetItemValueNames(IReadOnlyList<ItemValueType> itemValueTypes, IReadOnlyList<string> withItemTypes, IReadOnlyList<string> excludeItemTypes)
{
using var context = _dbProvider.CreateDbContext();
@@ -108,11 +144,6 @@ public sealed partial class BaseItemRepository
{
ArgumentNullException.ThrowIfNull(filter);
- if (!filter.Limit.HasValue)
- {
- filter.EnableTotalRecordCount = false;
- }
-
using var context = _dbProvider.CreateDbContext();
var innerQueryFilter = TranslateQuery(context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)), context, new InternalItemsQuery(filter.User)
@@ -132,21 +163,16 @@ public sealed partial class BaseItemRepository
IsSeries = filter.IsSeries
});
- // Keep this as an IQueryable sub-select. Materializing to a list would inline one
- // bound parameter per CleanValue and hit SQLite's variable cap on libraries with
- // high-cardinality value types (e.g. tens of thousands of artists).
- var matchingCleanValues = context.ItemValuesMap
- .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
- .Join(
- innerQueryFilter,
- ivm => ivm.ItemId,
- g => g.Id,
- (ivm, g) => ivm.ItemValue.CleanValue)
- .Distinct();
-
var innerQuery = PrepareItemQuery(context, filter)
.Where(e => e.Type == returnType)
- .Where(e => matchingCleanValues.Contains(e.CleanName!));
+ .Where(e => context.ItemValuesMap
+ .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type) && ivm.ItemValue.CleanValue == e.CleanName)
+ .Join(
+ innerQueryFilter,
+ ivm => ivm.ItemId,
+ g => g.Id,
+ (ivm, g) => ivm.ItemId)
+ .Any());
var outerQueryFilter = new InternalItemsQuery(filter.User)
{
@@ -169,22 +195,42 @@ public sealed partial class BaseItemRepository
ExcludeItemIds = filter.ExcludeItemIds
};
- // 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
- // ApplyOrder runs the caller's actual sort.
+ // Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) into one
+ // representative id per group, then materialize the representative ids once.
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];
+ List<Guid> representativeIds;
+ if (isMusicArtist)
+ {
+ // For MusicArtist, prefer the entity from a library the user can actually access.
+ // Materialize to prevent correlated per-group first-row queries which hurt performance.
+ var topParentIds = filter.TopParentIds;
+ representativeIds = masterQuery
+ .Select(e => new { e.Id, e.PresentationUniqueKey, e.TopParentId })
+ .AsEnumerable()
+ .GroupBy(e => e.PresentationUniqueKey)
+ .Select(g => g
+ .OrderBy(e => topParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1)
+ .ThenBy(e => e.Id)
+ .First().Id)
+ .ToList();
+ }
+ else
+ {
+ representativeIds = masterQuery
+ .GroupBy(e => e.PresentationUniqueKey)
+ .Select(g => g.Min(e => e.Id))
+ .ToList();
+ }
var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
if (filter.EnableTotalRecordCount)
{
- result.TotalRecordCount = representativeIds.Count();
+ result.TotalRecordCount = representativeIds.Count;
}
var query = ApplyNavigations(
- context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => representativeIds.Contains(e.Id)),
+ context.BaseItems.AsNoTracking().AsSingleQuery().WhereOneOrMany(representativeIds, e => e.Id),
filter);
query = ApplyOrder(query, filter, context);
@@ -265,8 +311,8 @@ public sealed partial class BaseItemRepository
var itemIds = itemCountQuery.Select(e => e.Id);
// Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite)
- // Instead, start from ItemValueMaps and join with BaseItems
- return context.ItemValuesMap
+ // Instead, start from ItemValueMaps and join with BaseItems.
+ var rawCounts = context.ItemValuesMap
.Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
.Where(ivm => itemIds.Contains(ivm.ItemId))
.Join(
@@ -276,18 +322,47 @@ public sealed partial class BaseItemRepository
(ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type })
.GroupBy(x => new { x.CleanName, x.Type })
.Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() })
- .GroupBy(x => x.CleanName)
- .ToDictionary(
- g => g.Key,
- g => new ItemCounts
+ .AsEnumerable();
+
+ var countsByCleanName = new Dictionary<string, ItemCounts>();
+ foreach (var group in rawCounts.GroupBy(x => x.CleanName))
+ {
+ var counts = new ItemCounts();
+ foreach (var row in group)
+ {
+ if (row.Type == seriesTypeName)
+ {
+ counts.SeriesCount += row.Count;
+ }
+ else if (row.Type == episodeTypeName)
{
- SeriesCount = g.Where(x => x.Type == seriesTypeName).Sum(x => x.Count),
- EpisodeCount = g.Where(x => x.Type == episodeTypeName).Sum(x => x.Count),
- MovieCount = g.Where(x => x.Type == movieTypeName).Sum(x => x.Count),
- AlbumCount = g.Where(x => x.Type == musicAlbumTypeName).Sum(x => x.Count),
- ArtistCount = g.Where(x => x.Type == musicArtistTypeName).Sum(x => x.Count),
- SongCount = g.Where(x => x.Type == audioTypeName).Sum(x => x.Count),
- TrailerCount = g.Where(x => x.Type == trailerTypeName).Sum(x => x.Count),
- });
+ counts.EpisodeCount += row.Count;
+ }
+ else if (row.Type == movieTypeName)
+ {
+ counts.MovieCount += row.Count;
+ }
+ else if (row.Type == musicAlbumTypeName)
+ {
+ counts.AlbumCount += row.Count;
+ }
+ else if (row.Type == musicArtistTypeName)
+ {
+ counts.ArtistCount += row.Count;
+ }
+ else if (row.Type == audioTypeName)
+ {
+ counts.SongCount += row.Count;
+ }
+ else if (row.Type == trailerTypeName)
+ {
+ counts.TrailerCount += row.Count;
+ }
+ }
+
+ countsByCleanName[group.Key] = counts;
+ }
+
+ return countsByCleanName;
}
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
index d6ddf8f5c8..05ff720ddf 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
@@ -35,11 +35,40 @@ public sealed partial class BaseItemRepository
{
dbQuery = TranslateQuery(dbQuery, context, filter);
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
+ dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter);
dbQuery = ApplyNavigations(dbQuery, filter);
return dbQuery;
}
+ /// <summary>
+ /// Trims an ordered query down to the AdjacentTo item and its immediate neighbours.
+ /// </summary>
+ private IQueryable<BaseItemEntity> ApplyAdjacencyFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
+ {
+ if (filter.AdjacentTo.IsNullOrEmpty())
+ {
+ return dbQuery;
+ }
+
+ // Adjacency is relative to the result set and the order the query asked for, so the ids have
+ // to be read back in that order.
+ var orderedIds = dbQuery.Select(e => e.Id).ToList();
+ var index = orderedIds.IndexOf(filter.AdjacentTo.Value);
+ if (index < 0)
+ {
+ // The item isn't part of this result set, so it has no neighbours in it either.
+ return dbQuery.Take(0);
+ }
+
+ var start = Math.Max(index - 1, 0);
+ var adjacentIds = orderedIds.GetRange(start, Math.Min(index + 2, orderedIds.Count) - start);
+
+ var adjacentQuery = context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => adjacentIds.Contains(e.Id));
+
+ return ApplyOrder(adjacentQuery, filter, context);
+ }
+
private IQueryable<BaseItemEntity> ApplyQueryPaging(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
if (filter.Limit.HasValue || filter.StartIndex.HasValue)
@@ -62,18 +91,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)
@@ -241,8 +273,8 @@ public sealed partial class BaseItemRepository
dbQuery = dbQuery.Include(e => e.Images);
}
- // Include LinkedChildEntities for container types and videos that use them
- // (BoxSet, Playlist, CollectionFolder for manual linking; Video, Movie for alternate versions).
+ // Include LinkedChildEntities for container types and videos that use them (BoxSet, Playlist,
+ // CollectionFolder for manual linking; every video type for alternate versions).
// When IncludeItemTypes is empty (any type may be returned), always include them to ensure
// LinkedChildren are loaded before items are saved back, preventing accidental deletion.
var linkedChildTypes = new[]
@@ -251,7 +283,10 @@ public sealed partial class BaseItemRepository
BaseItemKind.Playlist,
BaseItemKind.CollectionFolder,
BaseItemKind.Video,
- BaseItemKind.Movie
+ BaseItemKind.Movie,
+ BaseItemKind.Episode,
+ BaseItemKind.MusicVideo,
+ BaseItemKind.Trailer
};
if (filter.IncludeItemTypes.Length == 0 || filter.IncludeItemTypes.Any(linkedChildTypes.Contains))
{
@@ -387,12 +422,24 @@ public sealed partial class BaseItemRepository
var baseQuery = context.BaseItems
.AsNoTracking()
- .Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem);
+ .Where(b => allDescendantIds.Contains(b.Id))
+ .Where(DescendantQueryHelper.IsCountableLeaf);
return ApplyAccessFiltering(context, baseQuery, filter);
}
/// <summary>
+ /// Checks whether the user restricts access to items by parental rating or tags.
+ /// </summary>
+ /// <param name="filter">The query filter.</param>
+ /// <returns><c>true</c> if the query carries parental restrictions.</returns>
+ private static bool RequiresParentalRestrictions(InternalItemsQuery filter)
+ => filter.IncludeInheritedTags.Length > 0
+ || filter.ExcludeInheritedTags.Length > 0
+ || filter.MaxParentalRating is not null
+ || filter.BlockUnratedItems.Length > 0;
+
+ /// <summary>
/// Applies user access filtering to a query.
/// Includes TopParentIds, parental rating, and tag filtering.
/// </summary>
@@ -402,13 +449,127 @@ public sealed partial class BaseItemRepository
IQueryable<BaseItemEntity> baseQuery,
InternalItemsQuery filter)
{
- // Apply TopParentIds filtering (library folder access)
- if (filter.TopParentIds.Length > 0)
+ baseQuery = ApplyTopParentFiltering(context, baseQuery, filter);
+
+ 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.
+ if (!filter.IncludeOwnedItems)
{
- var topParentIds = filter.TopParentIds;
- baseQuery = baseQuery.Where(e => topParentIds.Contains(e.TopParentId!.Value));
+ baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
}
+ return baseQuery;
+ }
+
+ /// <summary>
+ /// Restricts a query to the libraries the user may open, exempting requested by-name items.
+ /// </summary>
+ /// <param name="context">The database context.</param>
+ /// <param name="baseQuery">The query to filter.</param>
+ /// <param name="filter">The query filter.</param>
+ /// <returns>The filtered query.</returns>
+ private IQueryable<BaseItemEntity> ApplyTopParentFiltering(
+ JellyfinDbContext context,
+ IQueryable<BaseItemEntity> baseQuery,
+ InternalItemsQuery filter)
+ {
+ var queryTopParentIds = filter.TopParentIds;
+ if (queryTopParentIds.Length == 0)
+ {
+ return baseQuery;
+ }
+
+ var exemptedItemByNameTypes = GetExemptedItemByNameTypes(filter);
+ if (exemptedItemByNameTypes.Count == 0)
+ {
+ return baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value);
+ }
+
+ baseQuery = baseQuery.Where(e => exemptedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value));
+ if (filter.UserHasContentRestrictions)
+ {
+ baseQuery = ApplyItemByNameAccessFiltering(baseQuery, context, filter, exemptedItemByNameTypes, queryTopParentIds);
+ }
+
+ return baseQuery;
+ }
+
+ /// <summary>
+ /// Returns the by-name types a query asks for, which carry no TopParentId to filter on.
+ /// </summary>
+ /// <param name="filter">The query filter.</param>
+ /// <returns>The type names exempt from library filtering.</returns>
+ private List<string> GetExemptedItemByNameTypes(InternalItemsQuery filter)
+ {
+ var includedItemByNameTypes = GetItemByNameTypesInQuery(filter);
+ if ((filter.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0)
+ {
+ return includedItemByNameTypes;
+ }
+
+ return _itemByNameKinds.Where(filter.IncludeItemTypes.Contains).Select(e => _itemTypeLookup.BaseItemKindNames[e]!).ToList();
+ }
+
+ /// <summary>
+ /// Keeps a by-name row only when at least one item behind its name is reachable for the user.
+ /// </summary>
+ /// <param name="baseQuery">The query to filter.</param>
+ /// <param name="context">The database context.</param>
+ /// <param name="filter">The query filter.</param>
+ /// <param name="itemByNameTypes">The exempted by-name type names.</param>
+ /// <param name="topParentIds">The libraries the user may open.</param>
+ /// <returns>The filtered query.</returns>
+ private IQueryable<BaseItemEntity> ApplyItemByNameAccessFiltering(
+ IQueryable<BaseItemEntity> baseQuery,
+ JellyfinDbContext context,
+ InternalItemsQuery filter,
+ IReadOnlyList<string> itemByNameTypes,
+ Guid[] topParentIds)
+ {
+ // IncludeOwnedItems: a credit on an alternate version of a reachable movie still counts.
+ var accessibleItems = ApplyAccessFiltering(
+ context,
+ context.BaseItems.AsNoTracking(),
+ new InternalItemsQuery(filter.User) { TopParentIds = topParentIds, IncludeOwnedItems = true });
+
+ var personType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
+ if (itemByNameTypes.Contains(personType))
+ {
+ baseQuery = baseQuery.Where(e => e.Type != personType
+ || context.Peoples.Any(p => p.Name == e.Name
+ && context.PeopleBaseItemMap.Any(m => m.PeopleId == p.Id && accessibleItems.Any(i => i.Id == m.ItemId))));
+ }
+
+ foreach (var (kind, valueTypes) in _itemByNameValueTypes)
+ {
+ var typeName = _itemTypeLookup.BaseItemKindNames[kind];
+ if (!itemByNameTypes.Contains(typeName))
+ {
+ continue;
+ }
+
+ baseQuery = baseQuery.Where(e => e.Type != typeName
+ || context.ItemValues.Any(v => valueTypes.Contains(v.Type) && v.CleanValue == e.CleanName
+ && context.ItemValuesMap.Any(m => m.ItemValueId == v.ItemValueId && accessibleItems.Any(i => i.Id == m.ItemId))));
+ }
+
+ return baseQuery;
+ }
+
+ /// <summary>
+ /// Applies the user's parental rating and tag restrictions to a query.
+ /// </summary>
+ /// <param name="context">The database context.</param>
+ /// <param name="baseQuery">The query to filter.</param>
+ /// <param name="filter">The query filter.</param>
+ /// <returns>The filtered query.</returns>
+ private IQueryable<BaseItemEntity> ApplyParentalRestrictions(
+ JellyfinDbContext context,
+ IQueryable<BaseItemEntity> baseQuery,
+ InternalItemsQuery filter)
+ {
// Apply parental rating filtering
if (filter.MaxParentalRating is not null)
{
@@ -444,6 +605,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,14 +614,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))
- // Exclude alternate versions (have PrimaryVersionId set) and 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));
+ // People don't carry the tags of the media they appear in and would never match
+ || e.Type == personTypeName);
}
return baseQuery;
@@ -496,62 +654,31 @@ public sealed partial class BaseItemRepository
}
/// <inheritdoc />
- public IQueryable<Guid> GetFullyPlayedFolderIdsQuery(JellyfinDbContext context, IQueryable<Guid> folderIds, User user)
+ public IQueryable<BaseItemEntity> GetAccessFilteredLeafItemsQuery(JellyfinDbContext context, User user, bool includeOwnedItems = false)
{
ArgumentNullException.ThrowIfNull(context);
- ArgumentNullException.ThrowIfNull(folderIds);
ArgumentNullException.ThrowIfNull(user);
- var filter = new InternalItemsQuery(user);
- var userId = user.Id;
-
var leafItems = context.BaseItems
.AsNoTracking()
- .Where(b => !b.IsFolder && !b.IsVirtualItem);
- leafItems = ApplyAccessFiltering(context, leafItems, filter);
+ .Where(DescendantQueryHelper.IsCountableLeaf);
- var playedLeafItems = leafItems
- .Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) });
-
- var ancestorLeaves = context.AncestorIds
- .Where(a => folderIds.Contains(a.ParentItemId))
- .Join(
- playedLeafItems,
- a => a.ItemId,
- b => b.Id,
- (a, b) => new { FolderId = a.ParentItemId, b.Id, b.Played });
-
- var linkedLeaves = context.LinkedChildren
- .Where(lc => folderIds.Contains(lc.ParentId))
- .Join(
- playedLeafItems,
- lc => lc.ChildId,
- b => b.Id,
- (lc, b) => new { FolderId = lc.ParentId, b.Id, b.Played });
+ return ApplyAccessFiltering(context, leafItems, new InternalItemsQuery(user) { IncludeOwnedItems = includeOwnedItems });
+ }
- var linkedFolderLeaves = context.LinkedChildren
- .Where(lc => folderIds.Contains(lc.ParentId))
- .Join(
- context.BaseItems.Where(b => b.IsFolder),
- lc => lc.ChildId,
- b => b.Id,
- (lc, b) => new { lc.ParentId, FolderChildId = b.Id })
- .Join(
- context.AncestorIds,
- x => x.FolderChildId,
- a => a.ParentItemId,
- (x, a) => new { x.ParentId, DescendantId = a.ItemId })
- .Join(
- playedLeafItems,
- x => x.DescendantId,
- b => b.Id,
- (x, b) => new { FolderId = x.ParentId, b.Id, b.Played });
-
- return ancestorLeaves
- .Union(linkedLeaves)
- .Union(linkedFolderLeaves)
- .GroupBy(x => x.FolderId)
- .Where(g => g.Select(x => x.Id).Distinct().Count() == g.Where(x => x.Played).Select(x => x.Id).Distinct().Count())
- .Select(g => g.Key);
+ /// <inheritdoc />
+ public Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> descendants)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(descendants);
+
+ // Descendants are reachable through the ancestor chain and - for BoxSets and Playlists - as
+ // linked children, which can themselves be folders contributing their own descendants.
+ // Every step is a correlated index seek, so only the rows the outer query keeps are visited
+ // and a folder is left as soon as its first matching descendant is found.
+ return e => context.AncestorIds.Any(a => a.ParentItemId == e.Id && descendants.Any(d => d.Id == a.ItemId))
+ || context.LinkedChildren.Any(lc => lc.ParentId == e.Id
+ && (descendants.Any(d => d.Id == lc.ChildId)
+ || context.AncestorIds.Any(a => a.ParentItemId == lc.ChildId && descendants.Any(d => d.Id == a.ItemId))));
}
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
index dc16c3b1b3..c9e08b1b5d 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
@@ -49,6 +49,7 @@ public sealed partial class BaseItemRepository
dbQuery = TranslateQuery(dbQuery, context, filter);
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
+ dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter);
if (filter.EnableTotalRecordCount)
{
@@ -75,6 +76,7 @@ public sealed partial class BaseItemRepository
dbQuery = TranslateQuery(dbQuery, context, filter);
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
+ dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter);
var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random);
@@ -108,7 +110,7 @@ public sealed partial class BaseItemRepository
PrepareFilterQuery(filter);
// Early exit if collection type is not supported
- if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music)
+ if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music and not CollectionType.unknown)
{
return [];
}
@@ -119,45 +121,61 @@ public sealed partial class BaseItemRepository
var baseQuery = PrepareItemQuery(context, filter);
baseQuery = TranslateQuery(baseQuery, context, filter);
- if (collectionType == CollectionType.tvshows)
+ if (collectionType is CollectionType.tvshows)
{
return GetLatestTvShowItems(context, baseQuery, filter, limit);
}
if (collectionType is CollectionType.movies)
{
- // Group by PresentationUniqueKey, pick the newest item per group.
- var topGroupItems = baseQuery
- .Where(e => e.PresentationUniqueKey != null)
- .GroupBy(e => e.PresentationUniqueKey)
- .Select(g => new
- {
- MaxDate = g.Max(e => e.DateCreated),
- FirstId = g.OrderByDescending(e => e.DateCreated).ThenByDescending(e => e.Id).Select(e => e.Id).First()
- })
- .OrderByDescending(g => g.MaxDate);
-
- var firstIdsQuery = filter.Limit.HasValue
- ? topGroupItems.Take(filter.Limit.Value).Select(g => g.FirstId)
- : topGroupItems.Select(g => g.FirstId);
-
- return LoadLatestByIds(context, firstIdsQuery, filter);
+ return GetLatestMovieItems(context, baseQuery, filter, limit);
}
- // Albums whose Id is the parent of any track matching the user's filter.
- var albumIdsWithMatchingTrack = context.AncestorIds
- .Join(baseQuery, ai => ai.ItemId, t => t.Id, (ai, _) => ai.ParentItemId);
+ if (collectionType is CollectionType.unknown)
+ {
+ var moviesQuery = baseQuery.Where(e => e.SeriesName == null);
+ var latestMovies = GetLatestMovieItems(context, moviesQuery, filter, limit);
+ var latestShows = GetLatestTvShowItems(context, baseQuery, filter, limit);
+
+ return latestMovies.Concat(latestShows)
+ .OrderByDescending(dto => dto.DateCreated)
+ .ThenByDescending(dto => dto.Id)
+ .Take(limit ?? int.MaxValue)
+ .ToList();
+ }
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]!;
- var topAlbumsQuery = context.BaseItems.AsNoTracking()
- .Where(album => album.Type == musicAlbumTypeName)
- .Where(album => albumIdsWithMatchingTrack.Contains(album.Id))
+ IQueryable<BaseItemEntity> topAlbumsQuery;
+
+ // When the query is scoped to whole libraries, read the newest albums directly by their own TopParentId.
+ if (filter.TopParentIds.Length > 0)
+ {
+ topAlbumsQuery = context.BaseItems.AsNoTracking()
+ .Where(album => album.Type == musicAlbumTypeName
+ && !album.IsVirtualItem
+ && album.TopParentId.HasValue)
+ .WhereOneOrMany(filter.TopParentIds, album => album.TopParentId!.Value);
+ }
+ else
+ {
+ // Fallback (e.g. AncestorIds-scoped callers): albums that are the parent of a matching track.
+ var albumIdsWithMatchingTrack = context.AncestorIds
+ .Join(baseQuery, ai => ai.ItemId, t => t.Id, (ai, _) => ai.ParentItemId);
+ topAlbumsQuery = context.BaseItems.AsNoTracking()
+ .Where(album => album.Type == musicAlbumTypeName)
+ .Where(album => albumIdsWithMatchingTrack.Contains(album.Id));
+ }
+
+ // The album is what gets returned, and neither branch above reads it through the
+ // user's filters, so its own parental restrictions have to be applied here: a
+ // matching track does not make an album the user may not see visible.
+ var orderedAlbums = ApplyParentalRestrictions(context, topAlbumsQuery, filter)
.OrderByDescending(album => album.DateCreated)
.ThenByDescending(album => album.Id);
- var albumIdsQuery = filter.Limit.HasValue
- ? topAlbumsQuery.Take(filter.Limit.Value).Select(a => a.Id)
- : topAlbumsQuery.Select(a => a.Id);
+ var albumIdsQuery = limit.HasValue
+ ? orderedAlbums.Take(limit.Value).Select(a => a.Id)
+ : orderedAlbums.Select(a => a.Id);
return LoadLatestByIds(context, albumIdsQuery, filter);
}
@@ -181,6 +199,62 @@ public sealed partial class BaseItemRepository
.ToArray()!;
}
+ private IReadOnlyList<BaseItemDto> LoadLatestByIds(
+ JellyfinDbContext context,
+ List<Guid> ids,
+ InternalItemsQuery filter)
+ {
+ if (ids.Count == 0)
+ {
+ return [];
+ }
+
+ var itemsQuery = ApplyNavigations(
+ context.BaseItems.AsNoTracking().WhereOneOrMany(ids, e => e.Id),
+ filter);
+
+ return itemsQuery
+ .OrderByDescending(e => e.DateCreated)
+ .ThenByDescending(e => e.Id)
+ .AsEnumerable()
+ .Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
+ .Where(dto => dto != null)
+ .ToArray()!;
+ }
+
+ /// <summary>
+ /// Gets the latest movies, deduplicated so each movie only appears once.
+ /// </summary>
+ /// <param name="context">The database context.</param>
+ /// <param name="baseQuery">The query to pull movies from, with filters already applied.</param>
+ /// <param name="filter">The original query filter, used when loading the final items.</param>
+ /// <param name="limit">How many items to return.</param>
+ /// <returns>The latest movies, newest first.</returns>
+ private IReadOnlyList<BaseItemDto> GetLatestMovieItems(
+ JellyfinDbContext context,
+ IQueryable<BaseItemEntity> baseQuery,
+ InternalItemsQuery filter,
+ int? limit)
+ {
+ // Pick, per PresentationUniqueKey, the newest item; return the newest `limit` of those.
+ // Build up until limit by streaming through results and deduplicating on the fly.
+ var orderedIds = baseQuery
+ .Where(e => e.PresentationUniqueKey != null)
+ .OrderByDescending(e => e.DateCreated)
+ .ThenByDescending(e => e.Id)
+ .Select(e => new { e.Id, e.PresentationUniqueKey });
+
+ // DistinctBy and Take are lazy, so enumeration stops as soon as limit distinct keys are read.
+ var firstIds = orderedIds
+ .AsEnumerable()
+ .DistinctBy(row => row.PresentationUniqueKey)
+ .Select(row => row.Id)
+ .Take(limit ?? int.MaxValue)
+ .ToList();
+
+ return LoadLatestByIds(context, firstIds, filter);
+ }
+
/// <summary>
/// Gets the latest TV show items with smart Season/Series container selection.
/// </summary>
@@ -381,6 +455,40 @@ public sealed partial class BaseItemRepository
seriesResults.Add((seasonId, seriesId, maxDate, mostRecentEpisodeId));
}
+ // Step 5b: A container is what gets returned, so it has to pass the user's access
+ // filters on its own - a matching episode does not make a Season or Series the user
+ // may not see visible. Containers that don't pass are replaced by their episode.
+ if (RequiresParentalRestrictions(filter) && entitiesToFetch.Count > 0)
+ {
+ var allowedContainerIds = ApplyParentalRestrictions(
+ context,
+ context.BaseItems.AsNoTracking().Where(e => entitiesToFetch.Contains(e.Id)),
+ filter)
+ .Select(e => e.Id)
+ .ToHashSet();
+
+ for (var i = 0; i < seriesResults.Count; i++)
+ {
+ var (seasonId, seriesId, maxDate, mostRecentEpisodeId) = seriesResults[i];
+ if (seasonId.HasValue && !allowedContainerIds.Contains(seasonId.Value))
+ {
+ seasonId = null;
+ }
+
+ if (seriesId.HasValue && !allowedContainerIds.Contains(seriesId.Value))
+ {
+ seriesId = null;
+ }
+
+ if (seasonId is null && seriesId is null)
+ {
+ entitiesToFetch.Add(mostRecentEpisodeId);
+ }
+
+ seriesResults[i] = (seasonId, seriesId, maxDate, mostRecentEpisodeId);
+ }
+ }
+
// Step 6: Fetch the Season/Series entities we decided to return
var entities = entitiesToFetch.Count > 0
? ApplyNavigations(
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index 624b1b561c..623c1ea0ab 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -31,6 +31,22 @@ public sealed partial class BaseItemRepository
private static readonly string TmdbProviderName = MetadataProvider.Tmdb.ToString().ToLowerInvariant();
private static readonly string TvdbProviderName = MetadataProvider.Tvdb.ToString().ToLowerInvariant();
+ // A fresh expression per access: EF rejects a query tree that reuses one lambda parameter
+ // instance across several lambdas, and this filter is combined into a tree more than once.
+ private static Expression<Func<BaseItemEntity, bool>> IsFolderFilter => e => e.IsFolder;
+
+ // "und" is the language filters' stand-in for a track that declares no language at all.
+ private static string NormalizeLanguage(string language)
+ => string.Equals(language, "und", StringComparison.OrdinalIgnoreCase) ? "und" : language;
+
+ // The primary versions whose alternate version satisfies a dimension bound. Anchored on
+ // PrimaryVersionId so the filtered index carries it rather than a scan of every item.
+ private static IQueryable<Guid> VersionsMatchingDimension(JellyfinDbContext context, Expression<Func<BaseItemEntity, bool>> bound)
+ => context.BaseItems
+ .Where(v => v.PrimaryVersionId != null)
+ .Where(bound)
+ .Select(v => v.PrimaryVersionId!.Value);
+
/// <inheritdoc />
public IQueryable<BaseItemEntity> TranslateQuery(
IQueryable<BaseItemEntity> baseQuery,
@@ -66,47 +82,86 @@ public sealed partial class BaseItemRepository
include4K = true;
}
- // Non-folders: check own resolution directly (no subquery).
- // Folders (Series, BoxSets): EXISTS check on descendants/linked children.
- // Using navigation properties (a.Item, lc.Child) produces efficient
- // EXISTS + JOIN instead of nested IN (SELECT ...) subqueries.
+ // A 4K remux of an SD primary is a version of the same item, so the bucket a caller filters
+ // on is the best any of the item's versions offers, not just the primary file's. Three sets,
+ // because a bucket is as much about what the version group does not have as what it does, and
+ // because an unprobed primary can still be placed by a version that does carry dimensions.
+ // The filtered PrimaryVersionId index keeps all three to the few items that have versions.
+ var versionsSd = VersionsMatchingDimension(context, v => v.Width > 0 && v.Width < HDWidth);
+ var versionsHd = VersionsMatchingDimension(context, v => v.Width >= HDWidth);
+ var versions4K = VersionsMatchingDimension(context, v => v.Width >= UHDWidth || v.Height >= UHDHeight);
+
+ // Only the SD test needs the Width > 0 guard against a row with no dimensions: such a row
+ // cannot reach the HD or 4K bound anyway, and EF lowers the HD bucket's negated "not itself
+ // 4K" guard to CASE WHEN ... THEN 0 ELSE 1, which already reads unknown as not 4K rather
+ // than propagating a null. Folders (Series, BoxSets) answer on their descendants, bucketed
+ // exactly as a top-level item is so that the two cannot disagree; the navigation properties
+ // (a.Item, lc.Child) give EXISTS + JOIN rather than nested IN (SELECT ...).
baseQuery = baseQuery.Where(e =>
- (!e.IsFolder && e.Width > 0
- && ((includeSD && e.Width < HDWidth)
- || (includeHD && e.Width >= HDWidth && !(e.Width >= UHDWidth || e.Height >= UHDHeight))
- || (include4K && (e.Width >= UHDWidth || e.Height >= UHDHeight))))
+ (!e.IsFolder
+ && ((includeSD
+ && ((e.Width > 0 && e.Width < HDWidth) || versionsSd.Contains(e.Id))
+ && !versionsHd.Contains(e.Id)
+ && !versions4K.Contains(e.Id))
+ || (includeHD
+ && (e.Width >= HDWidth || versionsHd.Contains(e.Id))
+ && !(e.Width >= UHDWidth || e.Height >= UHDHeight)
+ && !versions4K.Contains(e.Id))
+ || (include4K
+ && (e.Width >= UHDWidth || e.Height >= UHDHeight || versions4K.Contains(e.Id)))))
|| (e.IsFolder
&& (e.Children!.Any(a =>
- a.Item.Width > 0
- && ((includeSD && a.Item.Width < HDWidth)
- || (includeHD && a.Item.Width >= HDWidth && !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight))
- || (include4K && (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight))))
+ (includeSD
+ && ((a.Item.Width > 0 && a.Item.Width < HDWidth) || versionsSd.Contains(a.ItemId))
+ && !versionsHd.Contains(a.ItemId)
+ && !versions4K.Contains(a.ItemId))
+ || (includeHD
+ && (a.Item.Width >= HDWidth || versionsHd.Contains(a.ItemId))
+ && !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight)
+ && !versions4K.Contains(a.ItemId))
+ || (include4K
+ && (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight || versions4K.Contains(a.ItemId))))
|| context.LinkedChildren.Any(lc =>
lc.ParentId == e.Id
- && lc.Child!.Width > 0
- && ((includeSD && lc.Child.Width < HDWidth)
- || (includeHD && lc.Child.Width >= HDWidth && !(lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight))
- || (include4K && (lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight)))))));
- }
-
+ && ((includeSD
+ && ((lc.Child!.Width > 0 && lc.Child!.Width < HDWidth) || versionsSd.Contains(lc.ChildId))
+ && !versionsHd.Contains(lc.ChildId)
+ && !versions4K.Contains(lc.ChildId))
+ || (includeHD
+ && (lc.Child!.Width >= HDWidth || versionsHd.Contains(lc.ChildId))
+ && !(lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight)
+ && !versions4K.Contains(lc.ChildId))
+ || (include4K
+ && (lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight || versions4K.Contains(lc.ChildId))))))));
+ }
+
+ // Same reasoning as the resolution filter: a dimension bound is met if any version meets it.
if (minWidth.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Width >= minWidth);
+ var versionsWideEnough = VersionsMatchingDimension(context, v => v.Width >= minWidth);
+ baseQuery = baseQuery.Where(e => e.Width >= minWidth || versionsWideEnough.Contains(e.Id));
}
if (filter.MinHeight.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Height >= filter.MinHeight);
+ var minHeight = filter.MinHeight;
+ var versionsTallEnough = VersionsMatchingDimension(context, v => v.Height >= minHeight);
+ baseQuery = baseQuery.Where(e => e.Height >= minHeight || versionsTallEnough.Contains(e.Id));
}
+ // An upper bound inverts that: it is met only if no version breaches it, since the item's
+ // resolution is the best its version group offers.
if (maxWidth.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Width <= maxWidth);
+ var versionsTooWide = VersionsMatchingDimension(context, v => v.Width > maxWidth);
+ baseQuery = baseQuery.Where(e => e.Width <= maxWidth && !versionsTooWide.Contains(e.Id));
}
if (filter.MaxHeight.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Height <= filter.MaxHeight);
+ var maxHeight = filter.MaxHeight;
+ var versionsTooTall = VersionsMatchingDimension(context, v => v.Height > maxHeight);
+ baseQuery = baseQuery.Where(e => e.Height <= maxHeight && !versionsTooTall.Contains(e.Id));
}
if (filter.IsLocked.HasValue)
@@ -352,7 +407,7 @@ public sealed partial class BaseItemRepository
}
else
{
- baseQuery = baseQuery.Where(e => e.StartDate > now && e.EndDate < now);
+ baseQuery = baseQuery.Where(e => e.StartDate > now || e.EndDate < now);
}
}
@@ -366,14 +421,16 @@ public sealed partial class BaseItemRepository
p => p.Name,
(b, p) => p.Id);
+ var personTypes = filter.PersonTypes;
baseQuery = baseQuery
.Where(e => context.PeopleBaseItemMap
- .Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId)));
+ .Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId) && (personTypes.Length == 0 || personTypes.Contains(m.People.PersonType))));
}
if (!string.IsNullOrWhiteSpace(filter.Person))
{
- baseQuery = baseQuery.Where(e => e.Peoples!.Any(f => f.People.Name == filter.Person));
+ var personTypes = filter.PersonTypes;
+ baseQuery = baseQuery.Where(e => e.Peoples!.Any(f => f.People.Name == filter.Person && (personTypes.Length == 0 || personTypes.Contains(f.People.PersonType))));
}
if (!string.IsNullOrWhiteSpace(filter.ExternalSeriesId))
@@ -434,133 +491,124 @@ public sealed partial class BaseItemRepository
if (filter.IsLiked.HasValue)
{
- var isLiked = filter.IsLiked.Value;
- baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.Rating >= UserItemData.MinLikeValue) == isLiked);
- }
+ var likedIds = context.UserData
+ .Where(ud => ud.UserId == filter.User!.Id && ud.Rating >= UserItemData.MinLikeValue)
+ .Select(ud => ud.ItemId);
- if (filter.IsFavoriteOrLiked.HasValue)
- {
- var isFavoriteOrLiked = filter.IsFavoriteOrLiked.Value;
- baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.IsFavorite) == isFavoriteOrLiked);
+ baseQuery = filter.IsLiked.Value
+ ? baseQuery.Where(e => likedIds.Contains(e.Id))
+ : baseQuery.Where(e => !likedIds.Contains(e.Id));
}
- if (filter.IsFavorite.HasValue)
+ if (filter.IsFavoriteOrLiked.HasValue || filter.IsFavorite.HasValue)
{
- var isFavorite = filter.IsFavorite.Value;
- baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.IsFavorite) == isFavorite);
- }
+ var favoriteIds = context.UserData
+ .Where(ud => ud.UserId == filter.User!.Id && ud.IsFavorite)
+ .Select(ud => ud.ItemId);
- if (filter.IsPlayed.HasValue)
- {
- var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series);
- var hasBoxSet = filter.IncludeItemTypes.Contains(BaseItemKind.BoxSet);
-
- if (hasSeries || hasBoxSet)
+ if (filter.IsFavoriteOrLiked.HasValue)
{
- var userId = filter.User!.Id;
- var isPlayed = filter.IsPlayed.Value;
- var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
- var boxSetTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.BoxSet];
-
- // Series: played = at least one episode AND all episodes played; unplayed = otherwise.
- IQueryable<Guid> playedSeriesIds = hasSeries
- ? context.BaseItems
- .AsNoTracking()
- .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue)
- .GroupBy(e => e.SeriesId!.Value)
- .Where(g => !g.Any(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))
- .Select(g => g.Key)
- : Enumerable.Empty<Guid>().AsQueryable();
-
- // BoxSet: played = all children played.
- IQueryable<Guid> playedBoxSetIds = hasBoxSet
- ? GetFullyPlayedFolderIdsQuery(
- context,
- baseQuery.Where(e => e.Type == boxSetTypeName).Select(e => e.Id),
- filter.User!)
- : Enumerable.Empty<Guid>().AsQueryable();
-
- // Non-folder items: check UserData directly
- var playedItemIds = context.UserData
- .Where(ud => ud.UserId == userId && ud.Played)
- .Select(ud => ud.ItemId);
-
- if (isPlayed)
- {
- baseQuery = baseQuery.Where(e =>
- (e.Type == seriesTypeName && playedSeriesIds.Contains(e.Id))
- || (e.Type == boxSetTypeName && playedBoxSetIds.Contains(e.Id))
- || (e.Type != seriesTypeName && e.Type != boxSetTypeName && playedItemIds.Contains(e.Id)));
- }
- else
- {
- baseQuery = baseQuery.Where(e =>
- (e.Type == seriesTypeName && !playedSeriesIds.Contains(e.Id))
- || (e.Type == boxSetTypeName && !playedBoxSetIds.Contains(e.Id))
- || (e.Type != seriesTypeName && e.Type != boxSetTypeName && !playedItemIds.Contains(e.Id)));
- }
+ baseQuery = filter.IsFavoriteOrLiked.Value
+ ? baseQuery.Where(e => favoriteIds.Contains(e.Id))
+ : baseQuery.Where(e => !favoriteIds.Contains(e.Id));
}
- else
+
+ if (filter.IsFavorite.HasValue)
{
- var playedItemIds = context.UserData
- .Where(ud => ud.UserId == filter.User!.Id && ud.Played)
- .Select(ud => ud.ItemId);
- var isPlayedItem = filter.IsPlayed.Value;
- baseQuery = baseQuery.Where(e => playedItemIds.Contains(e.Id) == isPlayedItem);
+ baseQuery = filter.IsFavorite.Value
+ ? baseQuery.Where(e => favoriteIds.Contains(e.Id))
+ : baseQuery.Where(e => !favoriteIds.Contains(e.Id));
}
}
+ if (filter.IsPlayed.HasValue)
+ {
+ var userId = filter.User!.Id;
+
+ // Leaf items carry their own played state.
+ var playedItemIds = context.UserData
+ .Where(ud => ud.UserId == userId && ud.Played)
+ .Select(ud => ud.ItemId);
+
+ // Folders (Series, Seasons, BoxSets, albums, ...) have none and count as played once no
+ // descendant is left unplayed, matching what the DTO reports for them. This has to key off
+ // the item itself rather than off the requested item types: tag and collection listings mix
+ // folders and leaf items in a single query.
+ var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!)
+ .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
+
+ var isPlayedFilter = IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
+ .Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id)));
+
+ baseQuery = baseQuery.Where(filter.IsPlayed.Value ? isPlayedFilter : isPlayedFilter.Not());
+ }
+
if (filter.IsResumable.HasValue)
{
- var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series);
+ var userId = filter.User!.Id;
+ var isResumable = filter.IsResumable.Value;
- 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()
- .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue)
- .GroupBy(e => e.SeriesId!.Value)
- .Select(g => new
- {
- SeriesId = g.Key,
- HasInProgress = g.Any(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)),
- HasPlayed = g.Any(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)),
- HasUnplayed = g.Any(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))
- });
-
- // 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
- .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);
+ // In-progress user data rows; alternate versions track their own progress.
+ var inProgress = context.UserData
+ .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
- baseQuery = baseQuery.Where(e =>
- (e.Type == seriesTypeName && resumableSeriesIds.Contains(e.Id) == isResumable)
- || (e.Type != seriesTypeName && resumableItemIds.Contains(e.Id) == isResumable));
+ // Series and Seasons are resumable when a descendant is in progress, or when they hold both
+ // played and unplayed descendants (partially watched). Alternate versions keep their own
+ // progress, so they count towards the in-progress check but not towards the played/unplayed one.
+ var leafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!);
+ var inProgressLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!, includeOwnedItems: true)
+ .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0));
+
+ // Every other folder kind is a container rather than one continuous piece of media
+ var resumableFolderTypes = _resumableFolderKinds
+ .Select(kind => _itemTypeLookup.BaseItemKindNames.GetValueOrDefault(kind))
+ .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))))));
+
+ 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 = baseQuery.Where(folderIsResumableFilter
+ .Or(IsFolderFilter.Not().And(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.
+ // Items in no version group at all have no sibling that could eliminate them, so short-circuit the scan for those.
+ baseQuery = baseQuery.Where(e => e.IsFolder
+ || (e.PrimaryVersionId == null && !context.BaseItems.Any(a => a.PrimaryVersionId == e.Id))
+ || !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 = baseQuery.Where(IsFolderFilter.And(folderIsResumableFilter.Not())
+ .Or(IsFolderFilter.Not().And(e => !resumableMovieIds.Contains(e.Id))));
}
}
if (filter.ArtistIds.Length > 0)
{
- baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds);
+ baseQuery = baseQuery.WhereReferencedItem(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds);
}
if (filter.AlbumArtistIds.Length > 0)
@@ -586,18 +634,17 @@ 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)
{
- baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true);
+ baseQuery = baseQuery.WhereReferencedItem(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true);
}
if (filter.GenreIds.Count > 0)
{
- baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds.ToArray());
+ baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds);
}
if (filter.Genres.Count > 0)
@@ -623,7 +670,7 @@ public sealed partial class BaseItemRepository
if (filter.StudioIds.Length > 0)
{
- baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds.ToArray());
+ baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds);
}
if (filter.OfficialRatings.Length > 0)
@@ -742,10 +789,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)
@@ -762,104 +812,144 @@ public sealed partial class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage))
{
- var lang = filter.HasNoAudioTrackWithLanguage;
- var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang));
+ var lang = NormalizeLanguage(filter.HasNoAudioTrackWithLanguage);
+ var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang);
+ // A track only an alternate version carries still belongs to the item a caller sees, so the
+ // item's own streams alone do not decide this. Same for every stream filter below.
+ var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio && ms.Language == lang))
+ (!e.IsFolder
+ && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio
+ && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
+ && !versionsWithAudio.Contains(e.Id))
|| (e.IsFolder && !foldersWithAudio.Contains(e.Id)));
}
if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage))
{
- var lang = filter.HasNoInternalSubtitleTrackWithLanguage;
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false));
+ var lang = NormalizeLanguage(filter.HasNoInternalSubtitleTrackWithLanguage);
+ var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false);
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal && ms.Language == lang))
+ (!e.IsFolder
+ && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal
+ && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
+ && !versionsWithSubtitles.Contains(e.Id))
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
}
if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage))
{
- var lang = filter.HasNoExternalSubtitleTrackWithLanguage;
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true));
+ var lang = NormalizeLanguage(filter.HasNoExternalSubtitleTrackWithLanguage);
+ var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true);
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal && ms.Language == lang))
+ (!e.IsFolder
+ && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal
+ && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
+ && !versionsWithSubtitles.Contains(e.Id))
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
}
if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage))
{
- var lang = filter.HasNoSubtitleTrackWithLanguage;
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang));
+ var lang = NormalizeLanguage(filter.HasNoSubtitleTrackWithLanguage);
+ var undetermined = string.Equals(lang, "und", StringComparison.Ordinal);
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang);
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.Language == lang))
+ (!e.IsFolder
+ && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle
+ && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language))))
+ && !versionsWithSubtitles.Contains(e.Id))
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
}
if (filter.HasSubtitles.HasValue)
{
var hasSubtitles = filter.HasSubtitles.Value;
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasSubtitles());
+ var criteria = new HasSubtitles();
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
if (hasSubtitles)
{
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle))
+ (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)
+ || versionsWithSubtitles.Contains(e.Id)))
|| (e.IsFolder && foldersWithSubtitles.Contains(e.Id)));
}
else
{
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle))
+ (!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)
+ && !versionsWithSubtitles.Contains(e.Id))
|| (e.IsFolder && !foldersWithSubtitles.Contains(e.Id)));
}
}
if (filter.SubtitleLanguages.Count > 0)
{
- var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages));
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages);
+ var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle
- && (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))))
+ (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle
+ && (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))
+ || versionsWithSubtitles.Contains(e.Id)))
|| (e.IsFolder && foldersWithSubtitles.Contains(e.Id)));
}
if (filter.AudioLanguages.Count > 0)
{
- var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages));
+ var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages);
+ var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio
- && (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))))
+ (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio
+ && (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))
+ || versionsWithAudio.Contains(e.Id)))
|| (e.IsFolder && foldersWithAudio.Contains(e.Id)));
}
if (filter.HasChapterImages.HasValue)
{
var hasChapterImages = filter.HasChapterImages.Value;
- var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, new HasChapterImages());
+ var criteria = new HasChapterImages();
+ var versionsWithChapterImages = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria);
+ var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, criteria);
if (hasChapterImages)
{
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && e.Chapters!.Any(f => f.ImagePath != null))
+ (!e.IsFolder && (e.Chapters!.Any(f => f.ImagePath != null)
+ || versionsWithChapterImages.Contains(e.Id)))
|| (e.IsFolder && foldersWithChapterImages.Contains(e.Id)));
}
else
{
baseQuery = baseQuery
.Where(e =>
- (!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null))
+ (!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null)
+ && !versionsWithChapterImages.Contains(e.Id))
|| (e.IsFolder && !foldersWithChapterImages.Contains(e.Id)));
}
}
@@ -953,35 +1043,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)));
- }
-
- 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)));
- }
+ baseQuery = baseQuery.WhereHasAnyProviderId(filter.HasAnyProviderId);
}
if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0)
{
- var includeAny = filter.HasAnyProviderIds
- .SelectMany(kvp => kvp.Value.Select(v => $"{kvp.Key}:{v}"))
- .ToArray();
- if (includeAny.Length > 0)
- {
- baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeAny.Contains(f)));
- }
+ baseQuery = baseQuery.WhereHasAnyProviderIds(filter.HasAnyProviderIds);
}
if (filter.HasImdbId.HasValue)
@@ -1005,21 +1077,7 @@ public sealed partial class BaseItemRepository
: baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != TvdbProviderName));
}
- var queryTopParentIds = filter.TopParentIds;
-
- if (queryTopParentIds.Length > 0)
- {
- var includedItemByNameTypes = GetItemByNameTypesInQuery(filter);
- var enableItemsByName = (filter.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0;
- if (enableItemsByName && includedItemByNameTypes.Count > 0)
- {
- baseQuery = baseQuery.Where(e => includedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value));
- }
- else
- {
- baseQuery = baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value);
- }
- }
+ baseQuery = ApplyTopParentFiltering(context, baseQuery, filter);
if (filter.AncestorIds.Length > 0)
{
@@ -1027,6 +1085,15 @@ public sealed partial class BaseItemRepository
baseQuery = baseQuery.Where(e => e.Parents!.AsQueryable().Any(ancestorFilter));
}
+ if (filter.LinkedChildAncestorIds.Length > 0)
+ {
+ // Keep folder-like items (BoxSets, Playlists) whose linked children descend from any of the requested ancestor ids.
+ var linkedChildAncestorIds = filter.LinkedChildAncestorIds;
+ baseQuery = baseQuery.Where(e => context.LinkedChildren.Any(lc =>
+ lc.ParentId == e.Id
+ && lc.Child!.Parents!.Any(a => linkedChildAncestorIds.Contains(a.ParentItemId))));
+ }
+
if (!string.IsNullOrWhiteSpace(filter.AncestorWithPresentationUniqueKey))
{
baseQuery = baseQuery
@@ -1058,6 +1125,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);
@@ -1068,6 +1136,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}\"")));
}
@@ -1115,16 +1186,23 @@ public sealed partial class BaseItemRepository
: baseQuery.WhereNeitherItemNorDescendantMatches(context, isPlaceHolder);
}
+ // An extra is owned by the single version of an item it is named after, so an extra on any
+ // version counts for the item itself
+ IQueryable<Guid> WithPrimaryVersions(IQueryable<Guid> ownerIds)
+ => ownerIds.Concat(context.BaseItems
+ .Where(version => version.PrimaryVersionId != null && ownerIds.Contains(version.Id))
+ .Select(version => version.PrimaryVersionId!.Value));
+
if (filter.HasSpecialFeature.HasValue)
{
- var itemsWithExtras = context.BaseItems
+ var itemsWithExtras = WithPrimaryVersions(context.BaseItems
.Where(extra => extra.OwnerId != null
&& extra.ExtraType != null
&& extra.ExtraType != BaseItemExtraType.Unknown
&& extra.ExtraType != BaseItemExtraType.Trailer
&& extra.ExtraType != BaseItemExtraType.ThemeSong
&& extra.ExtraType != BaseItemExtraType.ThemeVideo)
- .Select(extra => extra.OwnerId!.Value)
+ .Select(extra => extra.OwnerId!.Value))
.Distinct();
Expression<Func<BaseItemEntity, bool>> hasExtras = e => itemsWithExtras.Contains(e.Id);
@@ -1136,9 +1214,9 @@ public sealed partial class BaseItemRepository
if (filter.HasTrailer.HasValue)
{
- var trailerOwnerIds = context.BaseItems
+ var trailerOwnerIds = WithPrimaryVersions(context.BaseItems
.Where(extra => extra.ExtraType == BaseItemExtraType.Trailer && extra.OwnerId != null)
- .Select(extra => extra.OwnerId!.Value);
+ .Select(extra => extra.OwnerId!.Value));
Expression<Func<BaseItemEntity, bool>> hasTrailer = e => trailerOwnerIds.Contains(e.Id);
@@ -1149,9 +1227,9 @@ public sealed partial class BaseItemRepository
if (filter.HasThemeSong.HasValue)
{
- var themeSongOwnerIds = context.BaseItems
+ var themeSongOwnerIds = WithPrimaryVersions(context.BaseItems
.Where(extra => extra.ExtraType == BaseItemExtraType.ThemeSong && extra.OwnerId != null)
- .Select(extra => extra.OwnerId!.Value);
+ .Select(extra => extra.OwnerId!.Value));
Expression<Func<BaseItemEntity, bool>> hasThemeSong = e => themeSongOwnerIds.Contains(e.Id);
@@ -1162,9 +1240,9 @@ public sealed partial class BaseItemRepository
if (filter.HasThemeVideo.HasValue)
{
- var themeVideoOwnerIds = context.BaseItems
+ var themeVideoOwnerIds = WithPrimaryVersions(context.BaseItems
.Where(extra => extra.ExtraType == BaseItemExtraType.ThemeVideo && extra.OwnerId != null)
- .Select(extra => extra.OwnerId!.Value);
+ .Select(extra => extra.OwnerId!.Value));
Expression<Func<BaseItemEntity, bool>> hasThemeVideo = e => themeVideoOwnerIds.Contains(e.Id);
@@ -1191,33 +1269,6 @@ public sealed partial class BaseItemRepository
}
}
- if (filter.AdjacentTo.HasValue && !filter.AdjacentTo.Value.IsEmpty())
- {
- var adjacentToId = filter.AdjacentTo.Value;
- var targetItem = context.BaseItems.Where(e => e.Id == adjacentToId).Select(e => new { e.SortName, e.Id }).FirstOrDefault();
- if (targetItem is not null)
- {
- var targetSortName = targetItem.SortName ?? string.Empty;
-
- // Fetch both prev and next adjacent items in a single query using Concat (UNION ALL).
- var adjacentIds = context.BaseItems
- .Where(e => string.Compare(e.SortName, targetSortName) < 0)
- .OrderByDescending(e => e.SortName)
- .Select(e => e.Id)
- .Take(1)
- .Concat(
- context.BaseItems
- .Where(e => string.Compare(e.SortName, targetSortName) > 0)
- .OrderBy(e => e.SortName)
- .Select(e => e.Id)
- .Take(1))
- .ToList();
-
- adjacentIds.Add(adjacentToId);
- baseQuery = baseQuery.Where(e => adjacentIds.Contains(e.Id));
- }
- }
-
return baseQuery;
}
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
index 94dedaeba8..1d2aa21853 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
@@ -46,6 +46,31 @@ public sealed partial class BaseItemRepository
private static readonly IReadOnlyList<ItemValueType> _getStudiosValueTypes = [ItemValueType.Studios];
private static readonly IReadOnlyList<ItemValueType> _getGenreValueTypes = [ItemValueType.Genre];
+ private static readonly BaseItemKind[] _itemByNameKinds =
+ [
+ BaseItemKind.Person,
+ BaseItemKind.Genre,
+ BaseItemKind.MusicGenre,
+ BaseItemKind.MusicArtist,
+ BaseItemKind.Studio
+ ];
+
+ private static readonly (BaseItemKind Kind, IReadOnlyList<ItemValueType> ValueTypes)[] _itemByNameValueTypes =
+ [
+ (BaseItemKind.Genre, _getGenreValueTypes),
+ (BaseItemKind.MusicGenre, _getGenreValueTypes),
+ (BaseItemKind.MusicArtist, _getAllArtistsValueTypes),
+ (BaseItemKind.Studio, _getStudiosValueTypes)
+ ];
+
+ // The only folder kinds whose children form a single viewing sequence, so playback progress on a
+ // child rolls up to them. Every other folder kind is a container that cannot be resumed.
+ private static readonly BaseItemKind[] _resumableFolderKinds =
+ [
+ BaseItemKind.Series,
+ BaseItemKind.Season
+ ];
+
/// <summary>
/// Initializes a new instance of the <see cref="BaseItemRepository"/> class.
/// </summary>
@@ -167,6 +192,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/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
index 604db9f839..c42b5f9581 100644
--- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
@@ -141,32 +141,32 @@ public class ItemCountService : IItemCountService
switch (kind)
{
case BaseItemKind.Person:
- baseQuery = context.PeopleBaseItemMap
+ baseQuery = ItemsById(context, context.PeopleBaseItemMap
.AsNoTracking()
.Where(m => m.People.Name == item.Name)
- .Select(m => m.Item);
+ .Select(m => m.ItemId));
break;
case BaseItemKind.MusicArtist:
- baseQuery = context.ItemValuesMap
+ baseQuery = ItemsById(context, context.ItemValuesMap
.AsNoTracking()
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
&& (ivm.ItemValue.Type == ItemValueType.Artist || ivm.ItemValue.Type == ItemValueType.AlbumArtist))
- .Select(ivm => ivm.Item);
+ .Select(ivm => ivm.ItemId));
break;
case BaseItemKind.Genre:
case BaseItemKind.MusicGenre:
- baseQuery = context.ItemValuesMap
+ baseQuery = ItemsById(context, context.ItemValuesMap
.AsNoTracking()
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
&& ivm.ItemValue.Type == ItemValueType.Genre)
- .Select(ivm => ivm.Item);
+ .Select(ivm => ivm.ItemId));
break;
case BaseItemKind.Studio:
- baseQuery = context.ItemValuesMap
+ baseQuery = ItemsById(context, context.ItemValuesMap
.AsNoTracking()
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
&& ivm.ItemValue.Type == ItemValueType.Studios)
- .Select(ivm => ivm.Item);
+ .Select(ivm => ivm.ItemId));
break;
case BaseItemKind.Year:
if (int.TryParse(item.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year))
@@ -254,22 +254,27 @@ public class ItemCountService : IItemCountService
return result;
}
+ private static IQueryable<BaseItemEntity> ItemsById(JellyfinDbContext context, IQueryable<Guid> itemIds)
+ => context.BaseItems.AsNoTracking().Where(e => itemIds.Contains(e.Id));
+
/// <inheritdoc/>
public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId)
{
+ ArgumentNullException.ThrowIfNull(filter);
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
- var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
+ var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played));
}
/// <inheritdoc/>
public int GetTotalCount(InternalItemsQuery filter, Guid ancestorId)
{
+ ArgumentNullException.ThrowIfNull(filter);
using var dbContext = _dbProvider.CreateDbContext();
- var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
+ var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return baseQuery.Count();
}
@@ -280,10 +285,23 @@ public class ItemCountService : IItemCountService
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
- var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId);
+ var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id);
}
+ private IQueryable<BaseItemEntity> BuildGroupedDescendantsQuery(JellyfinDbContext dbContext, InternalItemsQuery filter, Guid ancestorId)
+ {
+ var ancestorIds = GetPresentationKeyGroups(dbContext, [ancestorId])[ancestorId];
+ var descendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, ancestorIds).ToArray();
+
+ var baseQuery = dbContext.BaseItems
+ .AsNoTracking()
+ .WhereOneOrMany(descendantIds, b => b.Id)
+ .Where(DescendantQueryHelper.IsCountableLeaf);
+
+ return _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
+ }
+
/// <inheritdoc/>
public (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId)
{
@@ -291,9 +309,10 @@ public class ItemCountService : IItemCountService
ArgumentNullException.ThrowIfNull(filter.User);
using var dbContext = _dbProvider.CreateDbContext();
- var allDescendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, parentId);
+ var allDescendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, [parentId]).ToArray();
var baseQuery = dbContext.BaseItems
- .Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem);
+ .WhereOneOrMany(allDescendantIds, b => b.Id)
+ .Where(DescendantQueryHelper.IsCountableLeaf);
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id);
@@ -314,20 +333,29 @@ public class ItemCountService : IItemCountService
var parentIdsArray = parentIds.ToArray();
var hierarchicalCounts = dbContext.BaseItems
- .Where(b => b.ParentId.HasValue && parentIdsArray.Contains(b.ParentId.Value))
+ .Where(b => b.ParentId.HasValue)
+ .WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value)
.GroupBy(b => b.ParentId!.Value)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
var linkedCounts = dbContext.LinkedChildren
- .Where(lc => parentIdsArray.Contains(lc.ParentId))
+ .WhereOneOrMany(parentIdsArray, lc => lc.ParentId)
.GroupBy(lc => lc.ParentId)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
+ var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray);
+
var result = new Dictionary<Guid, int>();
foreach (var parentId in parentIds)
{
+ if (mergedChildCounts.TryGetValue(parentId, out var mergedCount))
+ {
+ result[parentId] = mergedCount;
+ continue;
+ }
+
var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0);
var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0);
@@ -337,6 +365,50 @@ public class ItemCountService : IItemCountService
return result;
}
+ private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds)
+ {
+ var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds)
+ .Where(group => group.Value.Count > 1)
+ .ToArray();
+
+ if (mergedGroups.Length == 0)
+ {
+ return [];
+ }
+
+ // Only merged folders.
+ var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray();
+ var children = dbContext.BaseItems
+ .AsNoTracking()
+ .Where(b => b.ParentId.HasValue)
+ .WhereOneOrMany(memberIds, b => b.ParentId!.Value)
+ .Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey })
+ .ToArray()
+ .GroupBy(b => b.ParentId)
+ .ToDictionary(
+ g => g.Key,
+ g => g.Select(b => string.IsNullOrEmpty(b.PresentationUniqueKey)
+ ? b.Id.ToString("N", CultureInfo.InvariantCulture)
+ : b.PresentationUniqueKey).ToArray());
+
+ var result = new Dictionary<Guid, int>();
+ foreach (var (parentId, members) in mergedGroups)
+ {
+ var childKeys = new HashSet<string>(StringComparer.Ordinal);
+ foreach (var member in members)
+ {
+ if (children.TryGetValue(member, out var keys))
+ {
+ childKeys.UnionWith(keys);
+ }
+ }
+
+ result[parentId] = childKeys.Count;
+ }
+
+ return result;
+ }
+
/// <inheritdoc/>
public Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user)
{
@@ -349,12 +421,15 @@ public class ItemCountService : IItemCountService
}
using var dbContext = _dbProvider.CreateDbContext();
- var folderIdsArray = folderIds.ToArray();
var filter = new InternalItemsQuery(user);
var userId = user.Id;
+ // Merged series and seasons are stored as one row per folder-item sharing a presentation key.
+ var groups = GetPresentationKeyGroups(dbContext, folderIds);
+ var folderIdsArray = groups.Values.SelectMany(members => members).Distinct().ToArray();
+
var leafItems = dbContext.BaseItems
- .Where(b => !b.IsFolder && !b.IsVirtualItem);
+ .Where(DescendantQueryHelper.IsCountableLeaf);
leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter);
var playedLeafItems = leafItems
@@ -394,7 +469,7 @@ public class ItemCountService : IItemCountService
b => b.Id,
(x, b) => new { FolderId = x.ParentId, b.Id, b.Played });
- var results = ancestorLeaves
+ var countsByFolder = ancestorLeaves
.Union(linkedLeaves)
.Union(linkedFolderLeaves)
.GroupBy(x => x.FolderId)
@@ -406,9 +481,73 @@ public class ItemCountService : IItemCountService
})
.ToDictionary(x => x.FolderId, x => (x.Played, x.Total));
+ var results = new Dictionary<Guid, (int Played, int Total)>();
+ foreach (var (folderId, members) in groups)
+ {
+ var played = 0;
+ var total = 0;
+
+ // Members of a group are distinct folders, so their leaves cannot overlap.
+ foreach (var member in members)
+ {
+ if (countsByFolder.TryGetValue(member, out var counts))
+ {
+ played += counts.Played;
+ total += counts.Total;
+ }
+ }
+
+ if (total > 0 || played > 0)
+ {
+ results[folderId] = (played, total);
+ }
+ }
+
return results;
}
+ private static Dictionary<Guid, List<Guid>> GetPresentationKeyGroups(JellyfinDbContext dbContext, IReadOnlyList<Guid> folderIds)
+ {
+ var requested = dbContext.BaseItems
+ .AsNoTracking()
+ .WhereOneOrMany(folderIds, e => e.Id)
+ .Select(e => new { e.Id, e.PresentationUniqueKey })
+ .ToArray();
+
+ var keys = requested
+ .Select(e => e.PresentationUniqueKey)
+ .Where(key => !string.IsNullOrEmpty(key))
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+
+ // Every item that is not merged carries a key derived from its own id, so in the common case
+ // each group resolves back to the single folder that was asked for.
+ var membersByKey = keys.Length == 0
+ ? []
+ : dbContext.BaseItems
+ .AsNoTracking()
+ .Where(e => e.IsFolder)
+ .WhereOneOrMany(keys, e => e.PresentationUniqueKey!)
+ .Select(e => new { e.Id, Key = e.PresentationUniqueKey! })
+ .ToArray()
+ .GroupBy(e => e.Key, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.Select(e => e.Id).ToList(), StringComparer.Ordinal);
+
+ var keyById = requested.ToDictionary(e => e.Id, e => e.PresentationUniqueKey);
+ var groups = new Dictionary<Guid, List<Guid>>();
+ foreach (var folderId in folderIds)
+ {
+ groups[folderId] = keyById.TryGetValue(folderId, out var key)
+ && !string.IsNullOrEmpty(key)
+ && membersByKey.TryGetValue(key, out var members)
+ && members.Count > 0
+ ? members
+ : [folderId];
+ }
+
+ return groups;
+ }
+
private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId)
{
var result = query
diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
index ffa5cff1f2..efff3457a3 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();
@@ -252,23 +257,19 @@ 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).ToArray();
+ var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToHashSet();
foreach (var item in tuples)
{
var entity = BaseItemMapper.Map(item.Item, _appHost);
entity.TopParentId = item.TopParent?.Id;
- if (!existingItems.Any(e => e == entity.Id))
+ if (!existingItems.Contains(entity.Id))
{
context.BaseItems.Add(entity);
}
else
{
- context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete();
- context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete();
- context.BaseItemMetadataFields.Where(e => e.ItemId == entity.Id).ExecuteDelete();
-
if (entity.Images is { Count: > 0 })
{
context.BaseItemImageInfos.AddRange(entity.Images);
@@ -309,9 +310,11 @@ public class ItemPersistenceService : IItemPersistenceService
}).ToArray();
context.ItemValues.AddRange(missingItemValues);
- var itemValuesStore = existingValues.Concat(missingItemValues).ToArray();
+ var itemValuesStore = existingValues
+ .Concat(missingItemValues)
+ .ToDictionary(e => (e.Type, e.Value));
var valueMap = itemValueMaps
- .Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore.First(g => g.Value == e.Value && g.Type == e.MagicNumber)).DistinctBy(e => e.ItemValueId).ToArray()))
+ .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();
@@ -396,6 +399,15 @@ public class ItemPersistenceService : IItemPersistenceService
}
}
+ // Owned rows of updated items are rewritten wholesale; cleared in one statement per table.
+ if (existingItems.Count > 0)
+ {
+ var updatedIds = existingItems.ToArray();
+ context.BaseItemProviders.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
+ context.BaseItemImageInfos.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
+ context.BaseItemMetadataFields.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
+ }
+
context.SaveChanges();
var folderIds = tuples
@@ -423,106 +435,144 @@ public class ItemPersistenceService : IItemPersistenceService
foreach (var item in tuples)
{
- if (item.Item is Folder folder)
+ // A container that was never hydrated cannot be used to rewrite its links: its empty
+ // array means "unknown", so clearing the stored rows would silently empty the item.
+ if (item.Item is Folder { LinkedChildrenLoaded: false })
+ {
+ continue;
+ }
+
+ if (item.Item is Folder or Video
+ && allLinkedChildrenByParent.TryGetValue(item.Item.Id, out var existingLinks)
+ && existingLinks.Count > 0)
{
- var existingLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(item.Item.Id)?.ToList() ?? new List<LinkedChildEntity>();
- if (folder.LinkedChildren.Length > 0)
+ // A video only owns its alternate version links; any other link on that parent is
+ // written by the folder branch below and must survive.
+ var staleLinks = item.Item is Folder
+ ? existingLinks
+ : existingLinks
+ .Where(e => e.ChildType is DbLinkedChildType.LocalAlternateVersion or DbLinkedChildType.LinkedAlternateVersion)
+ .ToList();
+
+ if (staleLinks.Count > 0)
{
+ context.LinkedChildren.RemoveRange(staleLinks);
+ }
+ }
+ }
+
+ context.SaveChanges();
+
+ // A LinkedChild's ItemId is only a cache.
+ var cachedChildIds = tuples
+ .Select(t => t.Item)
+ .OfType<Folder>()
+ .Where(f => f.LinkedChildrenLoaded)
+ .SelectMany(f => f.LinkedChildren)
+ .Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty())
+ .Select(lc => lc.ItemId!.Value)
+ .Distinct()
+ .ToList();
+
+ var knownChildIds = cachedChildIds.Count > 0
+ ? context.BaseItems
+ .WhereOneOrMany(cachedChildIds, e => e.Id)
+ .Select(e => e.Id)
+ .ToHashSet()
+ : [];
+
+ foreach (var item in tuples)
+ {
+ if (item.Item is Folder { LinkedChildrenLoaded: true } folder && folder.LinkedChildren.Length > 0)
+ {
#pragma warning disable CS0618 // Type or member is obsolete - legacy path resolution for old data
- var pathsToResolve = folder.LinkedChildren
- .Where(lc => (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty()) && !string.IsNullOrEmpty(lc.Path))
- .Select(lc => lc.Path)
- .Distinct()
- .ToList();
+ var pathsToResolve = folder.LinkedChildren
+ .Where(lc => !string.IsNullOrEmpty(lc.Path)
+ && (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty() || !knownChildIds.Contains(lc.ItemId.Value)))
+ .Select(lc => lc.Path)
+ .Distinct()
+ .ToList();
- var pathToIdMap = pathsToResolve.Count > 0
- ? context.BaseItems
- .Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
- .Select(e => new { e.Path, e.Id })
- .GroupBy(e => e.Path!)
- .ToDictionary(g => g.Key, g => g.First().Id)
- : [];
+ var pathToIdMap = pathsToResolve.Count > 0
+ ? context.BaseItems
+ .Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
+ .Select(e => new { e.Path, e.Id })
+ .GroupBy(e => e.Path!)
+ .ToDictionary(g => g.Key, g => g.First().Id)
+ : [];
- var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>();
- foreach (var linkedChild in folder.LinkedChildren)
+ var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>();
+ foreach (var linkedChild in folder.LinkedChildren)
+ {
+ var childItemId = linkedChild.ItemId;
+ if (!childItemId.HasValue || childItemId.Value.IsEmpty() || !knownChildIds.Contains(childItemId.Value))
{
- var childItemId = linkedChild.ItemId;
- if (!childItemId.HasValue || childItemId.Value.IsEmpty())
+ if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId))
{
- if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId))
- {
- childItemId = resolvedId;
- }
+ childItemId = resolvedId;
}
-#pragma warning restore CS0618
-
- if (childItemId.HasValue && !childItemId.Value.IsEmpty())
+ else if (Guid.TryParse(linkedChild.LibraryItemId, out var libraryItemId) && !libraryItemId.IsEmpty())
{
- resolvedChildren.Add((linkedChild, childItemId.Value));
+ childItemId = libraryItemId;
}
}
+#pragma warning restore CS0618
+ if (childItemId.HasValue && !childItemId.Value.IsEmpty())
+ {
+ resolvedChildren.Add((linkedChild, childItemId.Value));
+ }
+ }
+
+ // Playlists may legitimately contain the same item multiple times (e.g. a song repeated
+ // in an .m3u file). Every other container type keeps a single entry per child.
+ var isPlaylist = folder is Playlist;
+ if (!isPlaylist)
+ {
resolvedChildren = resolvedChildren
.GroupBy(c => c.ChildId)
.Select(g => g.Last())
.ToList();
+ }
- var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).ToList();
- var existingChildIds = childIdsToCheck.Count > 0
- ? context.BaseItems
- .Where(e => childIdsToCheck.Contains(e.Id))
- .Select(e => e.Id)
- .ToHashSet()
- : [];
-
- var isPlaylist = folder is Playlist;
- var sortOrder = 0;
- foreach (var (linkedChild, childId) in resolvedChildren)
- {
- if (!existingChildIds.Contains(childId))
- {
- _logger.LogWarning(
- "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} does not exist in database",
- item.Item.Name,
- item.Item.Id,
- childId);
- continue;
- }
-
- var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId);
- if (existingLink is null)
- {
- context.LinkedChildren.Add(new LinkedChildEntity()
- {
- ParentId = item.Item.Id,
- ChildId = childId,
- ChildType = (DbLinkedChildType)linkedChild.Type,
- SortOrder = isPlaylist ? sortOrder : null
- });
- }
- else
- {
- existingLink.SortOrder = isPlaylist ? sortOrder : null;
- existingLink.ChildType = (DbLinkedChildType)linkedChild.Type;
- existingLinkedChildren.Remove(existingLink);
- }
+ var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList();
+ var existingChildIds = childIdsToCheck.Count > 0
+ ? context.BaseItems
+ .WhereOneOrMany(childIdsToCheck, e => e.Id)
+ .Select(e => e.Id)
+ .ToHashSet()
+ : [];
- sortOrder++;
+ var sortOrder = 0;
+ foreach (var (linkedChild, childId) in resolvedChildren)
+ {
+ if (!existingChildIds.Contains(childId))
+ {
+#pragma warning disable CS0618 // Type or member is obsolete - legacy path is logged for diagnostics
+ _logger.LogWarning(
+ "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} (path {ChildPath}) does not exist in database",
+ item.Item.Name,
+ item.Item.Id,
+ childId,
+ linkedChild.Path ?? "unknown");
+#pragma warning restore CS0618
+ continue;
}
- }
- if (existingLinkedChildren.Count > 0)
- {
- context.LinkedChildren.RemoveRange(existingLinkedChildren);
+ context.LinkedChildren.Add(new LinkedChildEntity()
+ {
+ ParentId = item.Item.Id,
+ ChildId = childId,
+ ChildType = (DbLinkedChildType)linkedChild.Type,
+ SortOrder = sortOrder
+ });
+
+ sortOrder++;
}
}
if (item.Item is Video video)
{
- var existingLinkedChildren = (allLinkedChildrenByParent.GetValueOrDefault(video.Id) ?? new List<LinkedChildEntity>())
- .Where(e => (int)e.ChildType == 2 || (int)e.ChildType == 3)
- .ToList();
-
var newLinkedChildren = new List<(Guid ChildId, LinkedChildType Type)>();
if (video.LocalAlternateVersions.Length > 0)
@@ -557,9 +607,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();
@@ -570,7 +622,7 @@ public class ItemPersistenceService : IItemPersistenceService
.ToHashSet()
: [];
- int sortOrder = 0;
+ var sortOrder = 0;
foreach (var (childId, childType) in newLinkedChildren)
{
if (!existingChildIds.Contains(childId))
@@ -583,36 +635,27 @@ public class ItemPersistenceService : IItemPersistenceService
continue;
}
- var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId);
- if (existingLink is null)
+ context.LinkedChildren.Add(new LinkedChildEntity
{
- context.LinkedChildren.Add(new LinkedChildEntity
- {
- ParentId = video.Id,
- ChildId = childId,
- ChildType = (DbLinkedChildType)childType,
- SortOrder = sortOrder
- });
- }
- else
- {
- existingLink.ChildType = (DbLinkedChildType)childType;
- existingLink.SortOrder = sortOrder;
- existingLinkedChildren.Remove(existingLink);
- }
+ ParentId = video.Id,
+ ChildId = childId,
+ ChildType = (DbLinkedChildType)childType,
+ SortOrder = sortOrder
+ });
sortOrder++;
}
- if (existingLinkedChildren.Count > 0)
+ // A previously-linked LocalAlternateVersion that is no longer present becomes orphaned;
+ var previousLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(video.Id);
+ if (previousLinkedChildren is { Count: > 0 })
{
- var orphanedLocalVersionIds = existingLinkedChildren
- .Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion)
+ var newChildIds = newLinkedChildren.Select(c => c.ChildId).ToHashSet();
+ var orphanedLocalVersionIds = previousLinkedChildren
+ .Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion && !newChildIds.Contains(e.ChildId))
.Select(e => e.ChildId)
.ToList();
- context.LinkedChildren.RemoveRange(existingLinkedChildren);
-
if (orphanedLocalVersionIds.Count > 0)
{
var orphanedItems = context.BaseItems
diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
index 9e11b6be62..de112d7aa4 100644
--- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
+++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
@@ -7,6 +7,7 @@ using System.Collections.Generic;
using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
+using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -60,30 +61,50 @@ public class LinkedChildrenService : ILinkedChildrenService
}
/// <inheritdoc/>
+ public IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds)
+ {
+ if (itemIds.Count == 0)
+ {
+ return new HashSet<Guid>();
+ }
+
+ using var dbContext = _dbProvider.CreateDbContext();
+
+ return dbContext.LinkedChildren
+ .Where(lc => lc.ChildType == DbLinkedChildType.LocalAlternateVersion
+ || lc.ChildType == DbLinkedChildType.LinkedAlternateVersion)
+ .WhereOneOrMany(itemIds, lc => lc.ParentId)
+ .Select(lc => lc.ParentId)
+ .Distinct()
+ .ToHashSet();
+ }
+
+ /// <inheritdoc/>
public IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames)
{
using var dbContext = _dbProvider.CreateDbContext();
- var lowerNames = artistNames.Select(n => n.ToLowerInvariant()).ToArray();
+ var cleanNames = artistNames.Select(n => (Original: n, Clean: n.GetCleanValue())).ToArray();
+ var cleanValues = cleanNames.Select(x => x.Clean).ToArray();
+
var artists = dbContext.BaseItems
.AsNoTracking()
.Where(e => e.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]!)
- .Where(e => lowerNames.Contains(e.Name!.ToLower()))
+ .Where(e => cleanValues.Contains(e.CleanName))
.ToArray();
var lookup = artists
- .GroupBy(e => e.Name!, StringComparer.OrdinalIgnoreCase)
+ .GroupBy(e => e.CleanName!)
.ToDictionary(
g => g.Key,
- g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray(),
- StringComparer.OrdinalIgnoreCase);
+ g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
- var result = new Dictionary<string, MusicArtist[]>(artistNames.Count);
- foreach (var name in artistNames)
+ var result = new Dictionary<string, MusicArtist[]>(cleanNames.Length);
+ foreach (var (original, clean) in cleanNames)
{
- if (lookup.TryGetValue(name, out var artistArray))
+ if (lookup.TryGetValue(clean, out var artistArray))
{
- result[name] = artistArray;
+ result[original] = artistArray;
}
}
@@ -91,14 +112,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/>
@@ -148,12 +180,16 @@ public class LinkedChildrenService : ILinkedChildrenService
if (existingLink is null)
{
+ var nextSortOrder = (context.LinkedChildren
+ .Where(lc => lc.ParentId == parentId)
+ .Max(lc => (int?)lc.SortOrder) ?? -1) + 1;
+
context.LinkedChildren.Add(new Jellyfin.Database.Implementations.Entities.LinkedChildEntity
{
ParentId = parentId,
ChildId = childId,
ChildType = dbChildType,
- SortOrder = null
+ SortOrder = nextSortOrder
});
}
else
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/NextUpService.cs b/Jellyfin.Server.Implementations/Item/NextUpService.cs
index 725b4cfaac..f478daef23 100644
--- a/Jellyfin.Server.Implementations/Item/NextUpService.cs
+++ b/Jellyfin.Server.Implementations/Item/NextUpService.cs
@@ -98,7 +98,7 @@ public class NextUpService : INextUpService
.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
lastWatchedBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedBase, filter);
- // Use lightweight projection + client-side grouping to avoid correlated scalar subquery
+ // Use lightweight projection + client-side dedup to avoid the correlated scalar subquery
// per group that EF generates for GroupBy+OrderByDescending+FirstOrDefault.
var allPlayedLite = lastWatchedBase
.Select(e => new
@@ -110,15 +110,11 @@ public class NextUpService : INextUpService
})
.ToList();
- var lastWatchedInfo = new Dictionary<string, Guid>();
- foreach (var group in allPlayedLite.GroupBy(e => e.SeriesPresentationUniqueKey))
- {
- var lastWatched = group
- .OrderByDescending(e => e.ParentIndexNumber)
- .ThenByDescending(e => e.IndexNumber)
- .First();
- lastWatchedInfo[group.Key!] = lastWatched.Id;
- }
+ var lastWatchedInfo = allPlayedLite
+ .OrderByDescending(e => e.ParentIndexNumber)
+ .ThenByDescending(e => e.IndexNumber)
+ .DistinctBy(e => e.SeriesPresentationUniqueKey)
+ .ToDictionary(e => e.SeriesPresentationUniqueKey!, e => e.Id);
Dictionary<string, Guid> lastWatchedByDateInfo = new();
if (includeWatchedForRewatching)
@@ -144,11 +140,10 @@ public class NextUpService : INextUpService
(e, ud) => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate })
.ToList();
- foreach (var group in playedWithDates.GroupBy(x => x.SeriesPresentationUniqueKey))
- {
- var mostRecent = group.OrderByDescending(x => x.LastPlayedDate).First();
- lastWatchedByDateInfo[group.Key!] = mostRecent.EpisodeId;
- }
+ lastWatchedByDateInfo = playedWithDates
+ .OrderByDescending(x => x.LastPlayedDate)
+ .DistinctBy(x => x.SeriesPresentationUniqueKey)
+ .ToDictionary(x => x.SeriesPresentationUniqueKey!, x => x.EpisodeId);
}
var allLastWatchedIds = lastWatchedInfo.Values
diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
index d327b218a9..00b10e44a9 100644
--- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs
+++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
@@ -29,12 +29,30 @@ public static class OrderMapper
/// <returns>Func to be executed later for sorting query.</returns>
public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query, JellyfinDbContext jellyfinDbContext)
{
+ if (sortBy == ItemSortBy.DatePlayed)
+ {
+ // An item's played date is the newest of its own progress and that of its alternate versions,
+ // which track progress under their own ids. Matching both in one predicate ORs them together,
+ // which no index can serve: the user's whole UserData table gets scanned per sorted row.
+ // Two indexed lookups combined by MAX cost a seek each instead.
+ var userData = query.User is null
+ ? jellyfinDbContext.UserData
+ : jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id);
+
+ return e => userData
+ .Where(w => w.ItemId == e.Id)
+ .Select(w => w.LastPlayedDate)
+ .Concat(userData
+ .Where(w => w.Item!.PrimaryVersionId == e.Id)
+ .Select(w => w.LastPlayedDate))
+ .Max();
+ }
+
return (sortBy, query.User) switch
{
(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.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,
@@ -50,7 +68,7 @@ public static class OrderMapper
(ItemSortBy.DateCreated, _) => e => e.DateCreated,
(ItemSortBy.PremiereDate, _) => e => e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null),
(ItemSortBy.StartDate, _) => e => e.StartDate,
- (ItemSortBy.Name, _) => e => e.SortName,
+ (ItemSortBy.Name, _) => e => e.CleanName,
(ItemSortBy.CommunityRating, _) => e => e.CommunityRating,
(ItemSortBy.ProductionYear, _) => e => e.ProductionYear,
(ItemSortBy.CriticRating, _) => e => e.CriticRating,
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index 8f8741d00f..aaa363b046 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -21,10 +21,11 @@ namespace Jellyfin.Server.Implementations.Item;
/// </summary>
/// <param name="dbProvider">Efcore Factory.</param>
/// <param name="itemTypeLookup">Items lookup service.</param>
+/// <param name="queryHelpers">Shared item query helpers.</param>
/// <remarks>
/// Initializes a new instance of the <see cref="PeopleRepository"/> class.
/// </remarks>
-public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup) : IPeopleRepository
+public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup, IItemQueryHelpers queryHelpers) : IPeopleRepository
{
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider = dbProvider;
@@ -33,12 +34,13 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
{
using var context = _dbProvider.CreateDbContext();
var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter);
+ int? distinctNameCount = null;
// Include PeopleBaseItemMap
if (!filter.ItemId.IsEmpty())
{
dbQuery = dbQuery.Include(p => p.BaseItems!.Where(m => m.ItemId == filter.ItemId))
- .OrderBy(e => e.BaseItems!.First(e => e.ItemId == filter.ItemId).ListOrder)
+ .OrderBy(e => e.BaseItems!.Where(m => m.ItemId == filter.ItemId).Min(m => m.ListOrder))
.ThenBy(e => e.PersonType)
.ThenBy(e => e.Name);
}
@@ -46,17 +48,25 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
{
// The Peoples table has one row per (Name, PersonType), so the same person can
// appear multiple times (e.g. as Actor and GuestStar). Collapse to one row per
- // name so /Persons doesn't return the same BaseItem id repeatedly. Lowercase the
- // grouping key so case-only duplicates collapse together.
- var representativeIds = dbQuery
- .GroupBy(e => e.Name.ToLower())
- .Select(g => g.Min(e => e.Id));
- dbQuery = context.Peoples.AsNoTracking()
- .Where(p => representativeIds.Contains(p.Id))
- .OrderBy(e => e.Name);
+ // name so /Persons doesn't return the same BaseItem id repeatedly, keeping the
+ // lowest id per lowercased name so case-only duplicates collapse together.
+ var candidates = dbQuery;
+ dbQuery = candidates
+ .Where(p => !candidates.Any(other => other.Name.ToLower() == p.Name.ToLower() && other.Id < p.Id))
+ .OrderBy(e => e.Name.ToLower());
+
+ if (filter.EnableTotalRecordCount)
+ {
+ distinctNameCount = candidates.Select(e => e.Name.ToLower()).Distinct().Count();
+ }
+ }
+
+ var count = 0;
+ if (filter.EnableTotalRecordCount)
+ {
+ count = distinctNameCount ?? dbQuery.Count();
}
- var count = dbQuery.Count();
if (filter.StartIndex.HasValue && filter.StartIndex > 0)
{
dbQuery = dbQuery.Skip(filter.StartIndex.Value);
@@ -71,7 +81,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
{
StartIndex = filter.StartIndex ?? 0,
TotalRecordCount = count,
- Items = dbQuery.AsEnumerable().Select(Map).ToArray(),
+ Items = dbQuery.AsEnumerable().SelectMany(MapCredits).ToArray(),
};
}
@@ -79,7 +89,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
public IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter)
{
using var context = _dbProvider.CreateDbContext();
- var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter).Select(e => e.Name).Distinct();
+
+ IQueryable<string> dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter)
+ .Select(e => e.Name)
+ .Distinct()
+ .OrderBy(e => e);
if (filter.StartIndex.HasValue && filter.StartIndex > 0)
{
@@ -88,7 +102,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (filter.Limit > 0)
{
- dbQuery = dbQuery.OrderBy(e => e).Take(filter.Limit);
+ dbQuery = dbQuery.Take(filter.Limit);
}
return dbQuery.ToArray();
@@ -103,38 +117,67 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
person.Role = person.Role?.Trim() ?? string.Empty;
}
- // multiple metadata providers can provide the _same_ person; dedupe case-insensitively.
- people = people.DistinctBy(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray();
- var personKeys = people.Select(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray();
+ // Project the values every comparison below needs once, so neither the case folding nor the
+ // enum formatting is repeated per candidate.
+ var credits = people.Select(e => (Person: e, LoweredName: e.Name.ToLowerInvariant(), PersonType: e.Type.ToString(), LoweredRole: e.Role.ToLowerInvariant()));
+
+ // multiple metadata providers can provide the _same_ credit; dedupe case-insensitively.
+ // The role is part of the key because one person can hold several credits of the same type
+ // on an item, e.g. a Writer credited for both the Novel and the Screenplay.
+ 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();
using var transaction = context.Database.BeginTransaction();
var existingPersons = context.Peoples.Select(e => new
- {
- item = e,
- SelectionKey = e.Name.ToLower() + "-" + e.PersonType
- })
+ {
+ item = e,
+ SelectionKey = e.Name.ToLower() + "-" + e.PersonType
+ })
.Where(p => personKeys.Contains(p.SelectionKey))
.Select(f => f.item)
.ToArray();
- var toAdd = people
- .Where(e => !existingPersons.Any(f => string.Equals(f.Name, e.Name, StringComparison.OrdinalIgnoreCase) && f.PersonType == e.Type.ToString()))
- .Select(Map);
+ var existingPersonKeys = existingPersons.Select(e => (e.Name.ToLowerInvariant(), e.PersonType ?? string.Empty)).ToHashSet();
+
+ var toAdd = distinctPersons
+ .Where(e => !existingPersonKeys.Contains((e.LoweredName, e.PersonType)))
+ .Select(e => Map(e.Person))
+ .ToArray();
context.Peoples.AddRange(toAdd);
context.SaveChanges();
- var personsEntities = toAdd.Concat(existingPersons).ToArray();
+ // The Peoples table can hold case-only duplicates, so keep the first match per key just as
+ // the previous First() lookup did.
+ var personsEntities = new Dictionary<(string LoweredName, string PersonType), People>();
+ foreach (var entity in toAdd.Concat(existingPersons))
+ {
+ 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)
+ {
+ existingMapsByCredit.TryAdd((map.People.Name.ToLowerInvariant(), map.People.PersonType ?? string.Empty, map.Role?.ToLowerInvariant() ?? string.Empty), map);
+ }
var listOrder = 0;
- foreach (var person in people)
+ foreach (var credit in distinctCredits)
{
- var entityPerson = personsEntities.First(e => string.Equals(e.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.PersonType == person.Type.ToString());
- var existingMap = existingMaps.FirstOrDefault(e => string.Equals(e.People.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.People.PersonType == person.Type.ToString() && e.Role == person.Role);
- if (existingMap is null)
+ var entityPerson = personsEntities[(credit.LoweredName, credit.PersonType)];
+ if (existingMapsByCredit.TryGetValue((credit.LoweredName, credit.PersonType, credit.LoweredRole), out var existingMap))
+ {
+ // Update the order for existing mappings
+ existingMap.ListOrder = listOrder;
+ existingMap.SortOrder = credit.Person.SortOrder;
+ // person mapping already exists so remove from list
+ existingMaps.Remove(existingMap);
+ }
+ else
{
context.PeopleBaseItemMap.Add(new PeopleBaseItemMap()
{
@@ -143,18 +186,10 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
People = null!,
PeopleId = entityPerson.Id,
ListOrder = listOrder,
- SortOrder = person.SortOrder,
- Role = person.Role
+ SortOrder = credit.Person.SortOrder,
+ Role = credit.Person.Role
});
}
- else
- {
- // Update the order for existing mappings
- existingMap.ListOrder = listOrder;
- existingMap.SortOrder = person.SortOrder;
- // person mapping already exists so remove from list
- existingMaps.Remove(existingMap);
- }
listOrder++;
}
@@ -165,9 +200,102 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
transaction.Commit();
}
- private PersonInfo Map(People people)
+ /// <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;
+ }
+
+ /// <inheritdoc/>
+ public IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds)
+ {
+ using var context = _dbProvider.CreateDbContext();
+ var rows = context.PeopleBaseItemMap
+ .AsNoTracking()
+ .Where(m => itemIds.Contains(m.ItemId))
+ .OrderBy(m => m.ListOrder)
+ .Select(m => new
+ {
+ m.ItemId,
+ m.Role,
+ m.SortOrder,
+ m.People.Id,
+ m.People.Name,
+ m.People.PersonType
+ })
+ .ToList();
+
+ var result = new Dictionary<Guid, IReadOnlyList<PersonInfo>>();
+ foreach (var group in rows.GroupBy(r => r.ItemId))
+ {
+ var people = new List<PersonInfo>();
+ foreach (var row in group)
+ {
+ var personInfo = new PersonInfo
+ {
+ ItemId = row.ItemId,
+ Id = row.Id,
+ Name = row.Name,
+ Role = row.Role,
+ SortOrder = row.SortOrder
+ };
+ if (Enum.TryParse<PersonKind>(row.PersonType, out var kind))
+ {
+ personInfo.Type = kind;
+ }
+
+ people.Add(personInfo);
+ }
+
+ result[group.Key] = people;
+ }
+
+ return result;
+ }
+
+ private IEnumerable<PersonInfo> MapCredits(People people)
+ {
+ var mappings = people.BaseItems;
+ if (mappings is null || mappings.Count == 0)
+ {
+ return [Map(people, null)];
+ }
+
+ return mappings.OrderBy(m => m.ListOrder).Select(m => Map(people, m));
+ }
+
+ private PersonInfo Map(People people, PeopleBaseItemMap? mapping)
{
- var mapping = people.BaseItems?.FirstOrDefault();
var personInfo = new PersonInfo()
{
Id = people.Id,
@@ -200,18 +328,34 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (filter.User is not null && filter.IsFavorite.HasValue)
{
var personType = itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
- var oldQuery = query;
+ var userId = filter.User.Id;
+ var isFavorite = filter.IsFavorite.Value;
+ var favoriteItemIds = context.UserData
+ .Where(u => u.UserId.Equals(userId) && u.IsFavorite == isFavorite)
+ .Select(u => u.ItemId);
- query = context.UserData
- .Where(u => u.Item!.Type == personType && u.IsFavorite == filter.IsFavorite && u.UserId.Equals(filter.User.Id))
- .Join(oldQuery, e => e.Item!.Name, e => e.Name, (item, person) => person)
- .Distinct()
- .AsNoTracking();
+ var favoriteNames = context.BaseItems
+ .Where(b => b.Type == personType && favoriteItemIds.Contains(b.Id))
+ .Select(b => b.Name);
+
+ query = query.Where(e => favoriteNames.Contains(e.Name));
+ }
+
+ if (filter.AccessFilter is not null)
+ {
+ // Keep only people credited on at least one item the user can see.
+ var accessibleItems = queryHelpers.ApplyAccessFiltering(context, context.BaseItems.AsNoTracking(), filter.AccessFilter);
+ query = query.Where(e => context.PeopleBaseItemMap
+ .Any(m => m.PeopleId == e.Id && accessibleItems.Any(i => i.Id == m.ItemId)));
}
if (!filter.ItemId.IsEmpty())
{
- query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.ItemId)));
+ var itemId = filter.ItemId;
+ query = query.Where(e => context.PeopleBaseItemMap
+ .Where(m => m.ItemId.Equals(itemId))
+ .Select(m => m.PeopleId)
+ .Contains(e.Id));
}
if (filter.ParentId != null)
@@ -221,7 +365,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (!filter.AppearsInItemId.IsEmpty())
{
- query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.AppearsInItemId)));
+ var appearsInItemId = filter.AppearsInItemId;
+ query = query.Where(e => context.PeopleBaseItemMap
+ .Where(m => m.ItemId.Equals(appearsInItemId))
+ .Select(m => m.PeopleId)
+ .Contains(e.Id));
}
var queryPersonTypes = filter.PersonTypes.Where(IsValidPersonType).ToList();
@@ -239,7 +387,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/Security/AuthorizationContext.cs b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs
index e3fe517c49..8657cb7dbb 100644
--- a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs
+++ b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs
@@ -302,7 +302,7 @@ namespace Jellyfin.Server.Implementations.Security
}
else if (!escaped && token == '=')
{
- key = authorizationHeader[start.. i].Trim().ToString();
+ key = authorizationHeader[start..i].Trim().ToString();
start = i + 1;
}
}
diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs
index 13c7895f83..0989ce84ba 100644
--- a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs
+++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs
@@ -87,8 +87,9 @@ public static class StorageHelper
/// </summary>
private static string ResolvePath(string path)
{
- var parts = path.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
- var current = Path.DirectorySeparatorChar.ToString();
+ var root = Path.GetPathRoot(path) ?? Path.DirectorySeparatorChar.ToString();
+ var parts = path.Substring(root.Length).Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
+ var current = root;
foreach (var part in parts)
{
current = Path.Combine(current, part);
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..fea6084267 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
}
@@ -175,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.");
}
@@ -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,103 @@ 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);
+ SyncPermissions(dbUser, user.Permissions);
+ SyncPreferences(dbUser, user.Preferences);
+
+ 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);
+ }
+ }
+ }
+
+ private static void SyncPermissions(User dbUser, ICollection<Permission> source)
+ {
+ var incoming = new Dictionary<PermissionKind, bool>();
+ foreach (var permission in source)
+ {
+ incoming[permission.Kind] = permission.Value;
+ }
+
+ foreach (var existing in dbUser.Permissions)
+ {
+ if (incoming.Remove(existing.Kind, out var value))
+ {
+ // EF only marks the row modified if the value actually differs, so an update that
+ // touches nothing but the user row - a session activity stamp - writes no children.
+ existing.Value = value;
+ }
+ else
+ {
+ dbUser.Permissions.Remove(existing);
+ }
+ }
+
+ foreach (var (kind, value) in incoming)
+ {
+ dbUser.Permissions.Add(new Permission(kind, value));
+ }
+ }
+
+ private static void SyncPreferences(User dbUser, ICollection<Preference> source)
+ {
+ var incoming = new Dictionary<PreferenceKind, string>();
+ foreach (var preference in source)
+ {
+ incoming[preference.Kind] = preference.Value;
+ }
+
+ foreach (var existing in dbUser.Preferences)
+ {
+ if (incoming.Remove(existing.Kind, out var value))
+ {
+ existing.Value = value;
+ }
+ else
+ {
+ dbUser.Preferences.Remove(existing);
+ }
+ }
+
+ foreach (var (kind, value) in incoming)
+ {
+ dbUser.Preferences.Add(new Preference(kind, value));
}
}
@@ -257,10 +345,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 +354,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 +549,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 +564,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 +584,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 +604,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 +654,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,
@@ -801,8 +934,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;
@@ -811,7 +956,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;
}
@@ -938,32 +1083,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 +1108,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.");
+ }
+ }
+ }
+ }
}
}