aboutsummaryrefslogtreecommitdiff
path: root/Jellyfin.Server.Implementations/Item
diff options
context:
space:
mode:
Diffstat (limited to 'Jellyfin.Server.Implementations/Item')
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs11
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs89
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.cs8
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs11
-rw-r--r--Jellyfin.Server.Implementations/Item/OrderMapper.cs9
5 files changed, 85 insertions, 43 deletions
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
index d6ddf8f5c8..decd45ae2c 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
@@ -62,18 +62,21 @@ public sealed partial class BaseItemRepository
private IQueryable<BaseItemEntity> ApplyGroupingFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
- // Collapse duplicates sharing a presentation key (e.g. alternate versions) by picking
- // the min Id per group. Keep the grouped ids as an IQueryable sub-select; materializing
+ // Collapse duplicates sharing a presentation key (e.g. alternate versions), preferring the
+ // primary version (PrimaryVersionId is null) so detail pages and actions target it instead
+ // of an arbitrary alternate. Keep the grouped ids as an IQueryable sub-select; materializing
// to a List would inline one bound parameter per id and hit SQLite's variable cap.
var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter);
if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey)
{
- var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }).Select(e => e.Min(x => x.Id));
+ var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey })
+ .Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id));
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
}
else if (enableGroupByPresentationUniqueKey)
{
- var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey).Select(e => e.Min(x => x.Id));
+ var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey)
+ .Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id));
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
}
else if (filter.GroupBySeriesPresentationUniqueKey)
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index f33a65a703..52cebccc37 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -513,13 +513,17 @@ public sealed partial class BaseItemRepository
if (filter.IsResumable.HasValue)
{
var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series);
+ var userId = filter.User!.Id;
+ var isResumable = filter.IsResumable.Value;
+ var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
+ // In-progress user data rows; alternate versions track their own progress.
+ var inProgress = context.UserData
+ .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
+
+ IQueryable<Guid>? resumableSeriesIds = null;
if (hasSeries)
{
- var userId = filter.User!.Id;
- var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
- var isResumable = filter.IsResumable.Value;
-
// Aggregate per series in a single GROUP BY pass, instead of three full scans.
var seriesEpisodeStats = context.BaseItems
.AsNoTracking()
@@ -535,26 +539,44 @@ public sealed partial class BaseItemRepository
// A series is resumable if it has an in-progress episode,
// or if it has both played and unplayed episodes (partially watched).
- var resumableSeriesIds = seriesEpisodeStats
+ resumableSeriesIds = seriesEpisodeStats
.Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed))
.Select(s => s.SeriesId);
+ }
- // Non-series items: resumable if PlaybackPositionTicks > 0
- var resumableItemIds = context.UserData
- .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)
- .Select(ud => ud.ItemId);
-
- baseQuery = baseQuery.Where(e =>
- (e.Type == seriesTypeName && resumableSeriesIds.Contains(e.Id) == isResumable)
- || (e.Type != seriesTypeName && resumableItemIds.Contains(e.Id) == isResumable));
+ if (isResumable)
+ {
+ // Resume queries surface the version that was actually played, which may be an alternate.
+ // Match each version on its own progress rather than coalescing onto the primary.
+ var inProgressIds = inProgress.Select(ud => ud.ItemId);
+
+ baseQuery = hasSeries
+ ? baseQuery.Where(e =>
+ (e.Type == seriesTypeName && resumableSeriesIds!.Contains(e.Id))
+ || (e.Type != seriesTypeName && inProgressIds.Contains(e.Id)))
+ : baseQuery.Where(e => inProgressIds.Contains(e.Id));
+
+ // When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker.
+ baseQuery = baseQuery.Where(e => e.Type == seriesTypeName || !context.BaseItems
+ .Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
+ .Any(s =>
+ inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ && s.Id.CompareTo(e.Id) < 0)));
}
else
{
- var resumableItemIds = context.UserData
- .Where(ud => ud.UserId == filter.User!.Id && ud.PlaybackPositionTicks > 0)
- .Select(ud => ud.ItemId);
- var isResumable = filter.IsResumable.Value;
- baseQuery = baseQuery.Where(e => resumableItemIds.Contains(e.Id) == isResumable);
+ // Not-resumable queries operate on primaries only.
+ var resumableMovieIds = inProgress
+ .Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id);
+
+ baseQuery = hasSeries
+ ? baseQuery.Where(e =>
+ (e.Type == seriesTypeName && !resumableSeriesIds!.Contains(e.Id))
+ || (e.Type != seriesTypeName && !resumableMovieIds.Contains(e.Id)))
+ : baseQuery.Where(e => !resumableMovieIds.Contains(e.Id));
}
}
@@ -586,8 +608,7 @@ public sealed partial class BaseItemRepository
if (filter.AlbumIds.Length > 0)
{
- var subQuery = context.BaseItems.WhereOneOrMany(filter.AlbumIds, f => f.Id);
- baseQuery = baseQuery.Where(e => subQuery.Any(f => f.Name == e.Album));
+ baseQuery = baseQuery.Where(e => e.ParentId.HasValue && filter.AlbumIds.Contains(e.ParentId.Value));
}
if (filter.ExcludeArtistIds.Length > 0)
@@ -742,10 +763,13 @@ public sealed partial class BaseItemRepository
}
else if (filter.OwnerIds.Length == 0 && filter.ExtraTypes.Length == 0 && !filter.IncludeOwnedItems)
{
- // Exclude alternate versions and owned non-extra items from general queries.
- // Alternate versions have PrimaryVersionId set (pointing to their primary).
+ // Exclude owned non-extra items from general queries.
// Extras (trailers, etc.) have OwnerId set but also have ExtraType set - keep those.
- baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
+ // Alternate versions (PrimaryVersionId set) are normally excluded too, but resume queries
+ // keep them so the actually-played version can surface instead of collapsing onto the primary.
+ baseQuery = filter.IsResumable == true
+ ? baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null)
+ : baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
}
if (filter.OwnerIds.Length > 0)
@@ -953,24 +977,17 @@ public sealed partial class BaseItemRepository
if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0)
{
- var exclude = filter.ExcludeProviderIds.Select(e => $"{e.Key}:{e.Value}").ToArray();
- baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.All(f => !exclude.Contains(f)));
+ baseQuery = baseQuery.WhereExcludeProviderIds(filter.ExcludeProviderIds);
}
if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0)
{
- // Allow setting a null or empty value to get all items that have the specified provider set.
- var includeAny = filter.HasAnyProviderId.Where(e => string.IsNullOrEmpty(e.Value)).Select(e => e.Key).ToArray();
- if (includeAny.Length > 0)
- {
- baseQuery = baseQuery.Where(e => e.Provider!.Any(f => includeAny.Contains(f.ProviderId)));
- }
+ baseQuery = baseQuery.WhereHasAnyProviderId(filter.HasAnyProviderId);
+ }
- var includeSelected = filter.HasAnyProviderId.Where(e => !string.IsNullOrEmpty(e.Value)).Select(e => $"{e.Key}:{e.Value}").ToArray();
- if (includeSelected.Length > 0)
- {
- baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeSelected.Contains(f)));
- }
+ if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0)
+ {
+ baseQuery = baseQuery.WhereHasAnyProviderIds(filter.HasAnyProviderIds);
}
if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0)
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
index 94dedaeba8..57041276b7 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
@@ -167,6 +167,14 @@ public sealed partial class BaseItemRepository
return false;
}
+ // Resume queries surface the actually-played version (which may be an alternate sharing the
+ // primary's presentation key). The resumable filter already keeps one version per group, so
+ // presentation-key grouping must not collapse the surfaced version back onto the primary.
+ if (query.IsResumable == true)
+ {
+ return false;
+ }
+
if (query.GroupBySeriesPresentationUniqueKey)
{
return false;
diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
index ffa5cff1f2..b10f7c527e 100644
--- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
@@ -65,8 +65,13 @@ public class ItemPersistenceService : IItemPersistenceService
descendantIds.Add(id);
}
+ // Use WhereOneOrMany instead of a raw HashSet.Contains so large id sets are bound as a
+ // single parameter (json_each) rather than one SQL variable per id, which would otherwise
+ // overflow SQLite's variable limit when deleting many items at once (e.g. migrations).
+ var ownerIds = descendantIds.ToArray();
var extraIds = context.BaseItems
- .Where(e => e.OwnerId.HasValue && descendantIds.Contains(e.OwnerId.Value))
+ .Where(e => e.OwnerId.HasValue)
+ .WhereOneOrMany(ownerIds, e => e.OwnerId!.Value)
.Select(e => e.Id)
.ToArray();
@@ -557,9 +562,11 @@ public class ItemPersistenceService : IItemPersistenceService
}
}
+ // Deduplicate; local (file-based) relationships take priority over linked (user-merged)
+ // ones, matching the LinkedChildren migration.
newLinkedChildren = newLinkedChildren
.GroupBy(c => c.ChildId)
- .Select(g => g.Last())
+ .Select(g => g.OrderBy(c => c.Type == LinkedChildType.LocalAlternateVersion ? 0 : 1).First())
.ToList();
var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList();
diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
index d327b218a9..aac85d0131 100644
--- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs
+++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
@@ -34,7 +34,14 @@ public static class OrderMapper
(ItemSortBy.AirTime, _) => e => e.SortName,
(ItemSortBy.Runtime, _) => e => e.RunTimeTicks,
(ItemSortBy.Random, _) => e => EF.Functions.Random(),
- (ItemSortBy.DatePlayed, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.LastPlayedDate,
+ (ItemSortBy.DatePlayed, not null) => e =>
+ jellyfinDbContext.UserData
+ .Where(w => w.UserId == query.User.Id && (w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id))
+ .Max(f => f.LastPlayedDate),
+ (ItemSortBy.DatePlayed, null) => e =>
+ jellyfinDbContext.UserData
+ .Where(w => w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id)
+ .Max(f => f.LastPlayedDate),
(ItemSortBy.PlayCount, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.PlayCount,
(ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).Select(f => (bool?)f.IsFavorite).FirstOrDefault() ?? false,
(ItemSortBy.IsFolder, _) => e => e.IsFolder,