Merge remote-tracking branch 'upstream/master' into tmdb-missing-episodes
# Conflicts: # Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,19 @@ public static class ExpressionExtensions
|
||||
return predicates.Aggregate((aggregatePredicate, nextPredicate) => aggregatePredicate.Or(nextPredicate));
|
||||
}
|
||||
|
||||
/// <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>
|
||||
|
||||
@@ -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
|
||||
while (true)
|
||||
{
|
||||
using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings);
|
||||
document.WriteTo(jsonSerializer);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ public 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)
|
||||
|
||||
@@ -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();
|
||||
@@ -132,21 +168,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,32 +200,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. For MusicArtist, prefer the entity from a library the user
|
||||
// can actually access,since the same artist can have a folder in multiple libraries.
|
||||
// Keep as an IQueryable sub-select so paging is applied AFTER
|
||||
// ApplyOrder runs the caller's actual sort.
|
||||
// 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 isMusicArtist = returnType == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist];
|
||||
var representativeIds = isMusicArtist
|
||||
? masterQuery
|
||||
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 => filter.TopParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1)
|
||||
.OrderBy(e => topParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1)
|
||||
.ThenBy(e => e.Id)
|
||||
.First().Id)
|
||||
: masterQuery
|
||||
.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
representativeIds = masterQuery
|
||||
.GroupBy(e => e.PresentationUniqueKey)
|
||||
.Select(g => g.Min(e => e.Id));
|
||||
.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);
|
||||
@@ -275,8 +316,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(
|
||||
@@ -286,18 +327,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)
|
||||
{
|
||||
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.SeriesCount += row.Count;
|
||||
}
|
||||
else if (row.Type == episodeTypeName)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,18 +62,21 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
private IQueryable<BaseItemEntity> ApplyGroupingFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
|
||||
{
|
||||
// Collapse duplicates sharing a presentation key (e.g. alternate versions) by picking
|
||||
// the min Id per group. Keep the grouped ids as an IQueryable sub-select; materializing
|
||||
// Collapse duplicates sharing a presentation key (e.g. alternate versions), preferring the
|
||||
// primary version (PrimaryVersionId is null) so detail pages and actions target it instead
|
||||
// of an arbitrary alternate. Keep the grouped ids as an IQueryable sub-select; materializing
|
||||
// to a List would inline one bound parameter per id and hit SQLite's variable cap.
|
||||
var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter);
|
||||
if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey)
|
||||
{
|
||||
var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }).Select(e => e.Min(x => x.Id));
|
||||
var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey })
|
||||
.Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id));
|
||||
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
|
||||
}
|
||||
else if (enableGroupByPresentationUniqueKey)
|
||||
{
|
||||
var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey).Select(e => e.Min(x => x.Id));
|
||||
var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey)
|
||||
.Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id));
|
||||
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
|
||||
}
|
||||
else if (filter.GroupBySeriesPresentationUniqueKey)
|
||||
@@ -445,6 +448,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);
|
||||
@@ -453,7 +457,10 @@ public sealed partial class BaseItemRepository
|
||||
allowedTagItemIds.Contains(e.Id)
|
||||
|| (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value))
|
||||
|| e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId))
|
||||
|| (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)));
|
||||
|| (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value))
|
||||
|
||||
// People don't carry the tags of the media they appear in and would never match
|
||||
|| e.Type == personTypeName);
|
||||
}
|
||||
|
||||
// Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items.
|
||||
@@ -497,62 +504,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(DescendantQueryHelper.IsCountableLeaf);
|
||||
leafItems = ApplyAccessFiltering(context, leafItems, filter);
|
||||
|
||||
var playedLeafItems = leafItems
|
||||
.Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) });
|
||||
return ApplyAccessFiltering(context, leafItems, new InternalItemsQuery(user) { IncludeOwnedItems = includeOwnedItems });
|
||||
}
|
||||
|
||||
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 });
|
||||
/// <inheritdoc />
|
||||
public Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> descendants)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(descendants);
|
||||
|
||||
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 });
|
||||
|
||||
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);
|
||||
// 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))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,38 +126,54 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
if (collectionType is CollectionType.movies)
|
||||
{
|
||||
// Group by PresentationUniqueKey, pick the newest item per group.
|
||||
var topGroupItems = baseQuery
|
||||
// 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)
|
||||
.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);
|
||||
.OrderByDescending(e => e.DateCreated)
|
||||
.ThenByDescending(e => e.Id)
|
||||
.Select(e => new { e.Id, e.PresentationUniqueKey });
|
||||
|
||||
var firstIdsQuery = filter.Limit.HasValue
|
||||
? topGroupItems.Take(filter.Limit.Value).Select(g => g.FirstId)
|
||||
: topGroupItems.Select(g => g.FirstId);
|
||||
// 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, firstIdsQuery, filter);
|
||||
return LoadLatestByIds(context, firstIds, filter);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
var orderedAlbums = topAlbumsQuery
|
||||
.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 +197,29 @@ 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 TV show items with smart Season/Series container selection.
|
||||
/// </summary>
|
||||
|
||||
@@ -31,6 +31,10 @@ 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;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IQueryable<BaseItemEntity> TranslateQuery(
|
||||
IQueryable<BaseItemEntity> baseQuery,
|
||||
@@ -434,127 +438,113 @@ 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);
|
||||
|
||||
baseQuery = filter.IsLiked.Value
|
||||
? baseQuery.Where(e => likedIds.Contains(e.Id))
|
||||
: baseQuery.Where(e => !likedIds.Contains(e.Id));
|
||||
}
|
||||
|
||||
if (filter.IsFavoriteOrLiked.HasValue)
|
||||
if (filter.IsFavoriteOrLiked.HasValue || filter.IsFavorite.HasValue)
|
||||
{
|
||||
var isFavoriteOrLiked = filter.IsFavoriteOrLiked.Value;
|
||||
baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.IsFavorite) == isFavoriteOrLiked);
|
||||
}
|
||||
var favoriteIds = context.UserData
|
||||
.Where(ud => ud.UserId == filter.User!.Id && ud.IsFavorite)
|
||||
.Select(ud => ud.ItemId);
|
||||
|
||||
if (filter.IsFavorite.HasValue)
|
||||
{
|
||||
var isFavorite = filter.IsFavorite.Value;
|
||||
baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.IsFavorite) == isFavorite);
|
||||
if (filter.IsFavoriteOrLiked.HasValue)
|
||||
{
|
||||
baseQuery = filter.IsFavoriteOrLiked.Value
|
||||
? baseQuery.Where(e => favoriteIds.Contains(e.Id))
|
||||
: baseQuery.Where(e => !favoriteIds.Contains(e.Id));
|
||||
}
|
||||
|
||||
if (filter.IsFavorite.HasValue)
|
||||
{
|
||||
baseQuery = filter.IsFavorite.Value
|
||||
? baseQuery.Where(e => favoriteIds.Contains(e.Id))
|
||||
: baseQuery.Where(e => !favoriteIds.Contains(e.Id));
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.IsPlayed.HasValue)
|
||||
{
|
||||
var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series);
|
||||
var hasBoxSet = filter.IncludeItemTypes.Contains(BaseItemKind.BoxSet);
|
||||
var userId = filter.User!.Id;
|
||||
|
||||
if (hasSeries || hasBoxSet)
|
||||
{
|
||||
var userId = filter.User!.Id;
|
||||
var isPlayed = filter.IsPlayed.Value;
|
||||
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
|
||||
var boxSetTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.BoxSet];
|
||||
// Leaf items carry their own played state.
|
||||
var playedItemIds = context.UserData
|
||||
.Where(ud => ud.UserId == userId && ud.Played)
|
||||
.Select(ud => ud.ItemId);
|
||||
|
||||
// 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();
|
||||
// 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));
|
||||
|
||||
// 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();
|
||||
var isPlayedFilter = IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
|
||||
.Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id)));
|
||||
|
||||
// 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)));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
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 = 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)
|
||||
// In-progress user data rows; alternate versions track their own progress.
|
||||
var inProgress = context.UserData
|
||||
.Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
|
||||
|
||||
// Folders 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));
|
||||
|
||||
var folderResumableFilter = 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)
|
||||
{
|
||||
var userId = filter.User!.Id;
|
||||
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
|
||||
var isResumable = filter.IsResumable.Value;
|
||||
// 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);
|
||||
|
||||
// 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))
|
||||
});
|
||||
baseQuery = baseQuery.Where(IsFolderFilter.And(folderResumableFilter)
|
||||
.Or(IsFolderFilter.Not().And(e => inProgressIds.Contains(e.Id))));
|
||||
|
||||
// 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);
|
||||
|
||||
baseQuery = baseQuery.Where(e =>
|
||||
(e.Type == seriesTypeName && resumableSeriesIds.Contains(e.Id) == isResumable)
|
||||
|| (e.Type != seriesTypeName && resumableItemIds.Contains(e.Id) == isResumable));
|
||||
// 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(folderResumableFilter.Not())
|
||||
.Or(IsFolderFilter.Not().And(e => !resumableMovieIds.Contains(e.Id))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,10 +731,13 @@ public sealed partial class BaseItemRepository
|
||||
}
|
||||
else if (filter.OwnerIds.Length == 0 && filter.ExtraTypes.Length == 0 && !filter.IncludeOwnedItems)
|
||||
{
|
||||
// Exclude alternate versions and owned non-extra items from general queries.
|
||||
// Alternate versions have PrimaryVersionId set (pointing to their primary).
|
||||
// Exclude owned non-extra items from general queries.
|
||||
// Extras (trailers, etc.) have OwnerId set but also have ExtraType set - keep those.
|
||||
baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
|
||||
// Alternate versions (PrimaryVersionId set) are normally excluded too, but resume queries
|
||||
// keep them so the actually-played version can surface instead of collapsing onto the primary.
|
||||
baseQuery = filter.IsResumable == true
|
||||
? baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null)
|
||||
: baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
|
||||
}
|
||||
|
||||
if (filter.OwnerIds.Length > 0)
|
||||
@@ -1059,6 +1052,7 @@ public sealed partial class BaseItemRepository
|
||||
{
|
||||
var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray();
|
||||
var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist;
|
||||
var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
|
||||
var allowedTagItemIds = context.ItemValuesMap
|
||||
.Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
|
||||
.Select(f => f.ItemId);
|
||||
@@ -1069,6 +1063,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}\"")));
|
||||
}
|
||||
|
||||
@@ -167,6 +167,14 @@ public sealed partial class BaseItemRepository
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resume queries surface the actually-played version (which may be an alternate sharing the
|
||||
// primary's presentation key). The resumable filter already keeps one version per group, so
|
||||
// presentation-key grouping must not collapse the surfaced version back onto the primary.
|
||||
if (query.IsResumable == true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (query.GroupBySeriesPresentationUniqueKey)
|
||||
{
|
||||
return false;
|
||||
|
||||
@@ -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,6 +254,9 @@ 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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -423,106 +428,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 })
|
||||
{
|
||||
var existingLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(item.Item.Id)?.ToList() ?? new List<LinkedChildEntity>();
|
||||
if (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()
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.Item is Folder or Video
|
||||
&& allLinkedChildrenByParent.TryGetValue(item.Item.Id, out var existingLinks)
|
||||
&& existingLinks.Count > 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();
|
||||
|
||||
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)
|
||||
: [];
|
||||
if (staleLinks.Count > 0)
|
||||
{
|
||||
context.LinkedChildren.RemoveRange(staleLinks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>();
|
||||
foreach (var linkedChild in folder.LinkedChildren)
|
||||
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 => !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 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);
|
||||
}
|
||||
|
||||
sortOrder++;
|
||||
}
|
||||
}
|
||||
|
||||
if (existingLinkedChildren.Count > 0)
|
||||
var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList();
|
||||
var existingChildIds = childIdsToCheck.Count > 0
|
||||
? context.BaseItems
|
||||
.Where(e => childIdsToCheck.Contains(e.Id))
|
||||
.Select(e => e.Id)
|
||||
.ToHashSet()
|
||||
: [];
|
||||
|
||||
var sortOrder = 0;
|
||||
foreach (var (linkedChild, childId) in resolvedChildren)
|
||||
{
|
||||
context.LinkedChildren.RemoveRange(existingLinkedChildren);
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -572,7 +615,7 @@ public class ItemPersistenceService : IItemPersistenceService
|
||||
.ToHashSet()
|
||||
: [];
|
||||
|
||||
int sortOrder = 0;
|
||||
var sortOrder = 0;
|
||||
foreach (var (childId, childType) in newLinkedChildren)
|
||||
{
|
||||
if (!existingChildIds.Contains(childId))
|
||||
@@ -585,36 +628,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
|
||||
|
||||
@@ -159,12 +159,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -79,7 +79,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 +92,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();
|
||||
|
||||
@@ -170,7 +170,7 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
{
|
||||
ThrowIfInvalidUsername(newName);
|
||||
|
||||
if (oldName.Equals(newName, StringComparison.OrdinalIgnoreCase))
|
||||
if (oldName.Equals(newName, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("The new and old names must be different.");
|
||||
}
|
||||
@@ -616,6 +616,12 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
.SetProperty(f => f.LastActivityDate, date)
|
||||
.SetProperty(f => f.LastLoginDate, date))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// ExecuteUpdateAsync bypasses the change tracker, so keep the
|
||||
// returned entity in sync. Otherwise SessionManager.LogSessionActivity
|
||||
// saves this (stale) entity in full and reverts LastLoginDate.
|
||||
user.LastActivityDate = date;
|
||||
user.LastLoginDate = date;
|
||||
}
|
||||
|
||||
await dbContext.Users
|
||||
@@ -631,6 +637,7 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
|
||||
{
|
||||
user.SetPermission(PermissionKind.IsDisabled, true);
|
||||
dbContext.Update(user);
|
||||
await dbContext.SaveChangesAsync()
|
||||
.ConfigureAwait(false);
|
||||
await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
|
||||
@@ -882,8 +889,20 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
dbContext.Remove(user.ProfileImage);
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
// Remove the tracked profile image loaded from the database instead of the
|
||||
// detached instance on the passed in user. That instance can carry a stale,
|
||||
// never-persisted (temporary) key, which makes EF Core throw when it is marked
|
||||
// for deletion, leaving the profile image impossible to clear or replace.
|
||||
var dbUser = await UserQuery(dbContext)
|
||||
.AsTracking()
|
||||
.FirstOrDefaultAsync(u => u.Id == user.Id)
|
||||
.ConfigureAwait(false);
|
||||
if (dbUser?.ProfileImage is not null)
|
||||
{
|
||||
dbContext.Remove(dbUser.ProfileImage);
|
||||
dbUser.ProfileImage = null;
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
user.ProfileImage = null;
|
||||
@@ -892,7 +911,7 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
|
||||
internal static void ThrowIfInvalidUsername(string name)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name))
|
||||
if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name) && !string.Equals(name, ".", StringComparison.Ordinal) && !string.Equals(name, "..", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user