diff options
Diffstat (limited to 'Jellyfin.Server.Implementations/Item')
11 files changed, 316 insertions, 155 deletions
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs index 736388e9eb..c64e6ac068 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 diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs index 380c6e582c..c5b5fbf6d8 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Linq; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Querying; @@ -170,92 +169,50 @@ public sealed partial class BaseItemRepository ExcludeItemIds = filter.ExcludeItemIds }; - // Build the master query and collapse rows that share a PresentationUniqueKey - // (e.g. alternate versions) by picking the lowest Id per group. + // 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. var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter); - - var orderedMasterQuery = ApplyOrder(masterQuery, filter, context) - .GroupBy(e => e.PresentationUniqueKey) - .Select(g => g.Min(e => e.Id)); + var isMusicArtist = returnType == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; + var representativeIds = isMusicArtist + ? masterQuery + .GroupBy(e => e.PresentationUniqueKey) + .Select(g => g + .OrderBy(e => filter.TopParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1) + .ThenBy(e => e.Id) + .First().Id) + : masterQuery + .GroupBy(e => e.PresentationUniqueKey) + .Select(g => g.Min(e => e.Id)); var result = new QueryResult<(BaseItemDto, ItemCounts?)>(); if (filter.EnableTotalRecordCount) { - result.TotalRecordCount = orderedMasterQuery.Count(); + result.TotalRecordCount = representativeIds.Count(); } + var query = ApplyNavigations( + context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => representativeIds.Contains(e.Id)), + filter); + + query = ApplyOrder(query, filter, context); + if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0) { - orderedMasterQuery = orderedMasterQuery.Skip(filter.StartIndex.Value); + query = query.Skip(filter.StartIndex.Value); } if (filter.Limit.HasValue) { - orderedMasterQuery = orderedMasterQuery.Take(filter.Limit.Value); + query = query.Take(filter.Limit.Value); } - var masterIds = orderedMasterQuery.ToList(); - - var query = ApplyNavigations( - context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => masterIds.Contains(e.Id)), - filter); - - query = ApplyOrder(query, filter, context); - + result.StartIndex = filter.StartIndex ?? 0; if (filter.IncludeItemTypes.Length > 0) { - var typeSubQuery = new InternalItemsQuery(filter.User) - { - ExcludeItemTypes = filter.ExcludeItemTypes, - IncludeItemTypes = filter.IncludeItemTypes, - MediaTypes = filter.MediaTypes, - AncestorIds = filter.AncestorIds, - ExcludeItemIds = filter.ExcludeItemIds, - ItemIds = filter.ItemIds, - TopParentIds = filter.TopParentIds, - ParentId = filter.ParentId, - IsPlayed = filter.IsPlayed - }; - - var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery) - .Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type))); - - var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; - var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; - var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; - var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]; - var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; - var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; - var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; - 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 - var countsByCleanName = context.ItemValuesMap - .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) - .Where(ivm => itemIds.Contains(ivm.ItemId)) - .Join( - context.BaseItems, - ivm => ivm.ItemId, - e => e.Id, - (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 - { - 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), - }); - - result.StartIndex = filter.StartIndex ?? 0; + var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes); result.Items = [ .. query @@ -273,7 +230,6 @@ public sealed partial class BaseItemRepository } else { - result.StartIndex = filter.StartIndex ?? 0; result.Items = [ .. query @@ -287,4 +243,61 @@ public sealed partial class BaseItemRepository return result; } + + private Dictionary<string, ItemCounts> BuildItemCountsByCleanName( + Database.Implementations.JellyfinDbContext context, + InternalItemsQuery filter, + IReadOnlyList<ItemValueType> itemValueTypes) + { + var typeSubQuery = new InternalItemsQuery(filter.User) + { + ExcludeItemTypes = filter.ExcludeItemTypes, + IncludeItemTypes = filter.IncludeItemTypes, + MediaTypes = filter.MediaTypes, + AncestorIds = filter.AncestorIds, + ExcludeItemIds = filter.ExcludeItemIds, + ItemIds = filter.ItemIds, + TopParentIds = filter.TopParentIds, + ParentId = filter.ParentId, + IsPlayed = filter.IsPlayed + }; + + var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery) + .Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type))); + + var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; + var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; + var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode]; + var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]; + var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]; + var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio]; + var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer]; + 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 + .Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type)) + .Where(ivm => itemIds.Contains(ivm.ItemId)) + .Join( + context.BaseItems, + ivm => ivm.ItemId, + e => e.Id, + (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 + { + 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), + }); + } } 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 0abe981af8..1198f99473 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -390,7 +390,8 @@ public sealed partial class BaseItemRepository { if (filter.UseRawName == true) { - baseQuery = baseQuery.Where(e => e.Name == filter.Name); + var nameLower = filter.Name.ToLowerInvariant(); + baseQuery = baseQuery.Where(e => e.Name!.ToLower() == nameLower); } else { @@ -512,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() @@ -534,26 +539,49 @@ public sealed partial class BaseItemRepository // A series is resumable if it has an in-progress episode, // or if it has both played and unplayed episodes (partially watched). - var resumableSeriesIds = seriesEpisodeStats + resumableSeriesIds = seriesEpisodeStats .Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed)) .Select(s => s.SeriesId); + } - // Non-series items: resumable if PlaybackPositionTicks > 0 - var resumableItemIds = context.UserData - .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0) - .Select(ud => ud.ItemId); - - baseQuery = baseQuery.Where(e => - (e.Type == seriesTypeName && resumableSeriesIds.Contains(e.Id) == isResumable) - || (e.Type != seriesTypeName && resumableItemIds.Contains(e.Id) == isResumable)); + if (isResumable) + { + // Resume queries surface the version that was actually played, which may be an alternate. + // Match each version on its own progress rather than coalescing onto the primary. + var inProgressIds = inProgress.Select(ud => ud.ItemId); + + baseQuery = hasSeries + ? baseQuery.Where(e => + (e.Type == seriesTypeName && resumableSeriesIds!.Contains(e.Id)) + || (e.Type != seriesTypeName && inProgressIds.Contains(e.Id))) + : baseQuery.Where(e => inProgressIds.Contains(e.Id)); + + // When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker. + // Only in-progress siblings can eliminate a candidate: a version without progress has a NULL max LastPlayedDate, + // which is never greater and never ties. Restricting the sibling scan to the in-progress set keeps this bounded by + // the user's Continue Watching count instead of forcing a full BaseItems scan (COALESCE keys are non-indexable) per row. + baseQuery = baseQuery.Where(e => e.Type == seriesTypeName || !context.BaseItems + .Where(s => s.Id != e.Id + && inProgressIds.Contains(s.Id) + && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id)) + .Any(s => + inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) + || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate) + == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate) + && s.Id.CompareTo(e.Id) < 0))); } else { - var resumableItemIds = context.UserData - .Where(ud => ud.UserId == filter.User!.Id && ud.PlaybackPositionTicks > 0) - .Select(ud => ud.ItemId); - var isResumable = filter.IsResumable.Value; - baseQuery = baseQuery.Where(e => resumableItemIds.Contains(e.Id) == isResumable); + // Not-resumable queries operate on primaries only. + var resumableMovieIds = inProgress + .Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id); + + baseQuery = hasSeries + ? baseQuery.Where(e => + (e.Type == seriesTypeName && !resumableSeriesIds!.Contains(e.Id)) + || (e.Type != seriesTypeName && !resumableMovieIds.Contains(e.Id))) + : baseQuery.Where(e => !resumableMovieIds.Contains(e.Id)); } } @@ -585,8 +613,7 @@ public sealed partial class BaseItemRepository if (filter.AlbumIds.Length > 0) { - var subQuery = context.BaseItems.WhereOneOrMany(filter.AlbumIds, f => f.Id); - baseQuery = baseQuery.Where(e => subQuery.Any(f => f.Name == e.Album)); + baseQuery = baseQuery.Where(e => e.ParentId.HasValue && filter.AlbumIds.Contains(e.ParentId.Value)); } if (filter.ExcludeArtistIds.Length > 0) @@ -741,10 +768,13 @@ public sealed partial class BaseItemRepository } else if (filter.OwnerIds.Length == 0 && filter.ExtraTypes.Length == 0 && !filter.IncludeOwnedItems) { - // Exclude alternate versions and owned non-extra items from general queries. - // Alternate versions have PrimaryVersionId set (pointing to their primary). + // Exclude owned non-extra items from general queries. // Extras (trailers, etc.) have OwnerId set but also have ExtraType set - keep those. - baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null)); + // Alternate versions (PrimaryVersionId set) are normally excluded too, but resume queries + // keep them so the actually-played version can surface instead of collapsing onto the primary. + baseQuery = filter.IsResumable == true + ? baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null) + : baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null)); } if (filter.OwnerIds.Length > 0) @@ -823,6 +853,26 @@ public sealed partial class BaseItemRepository } } + if (filter.SubtitleLanguages.Count > 0) + { + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages)); + 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 && foldersWithSubtitles.Contains(e.Id))); + } + + if (filter.AudioLanguages.Count > 0) + { + var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages)); + 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 && foldersWithAudio.Contains(e.Id))); + } + if (filter.HasChapterImages.HasValue) { var hasChapterImages = filter.HasChapterImages.Value; @@ -932,23 +982,27 @@ 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) + 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) + { + 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 => includeSelected.Contains(f))); + baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeAny.Contains(f))); } } @@ -995,6 +1049,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 @@ -1056,8 +1119,12 @@ public sealed partial class BaseItemRepository if (filter.VideoTypes.Length > 0) { + // Dvds and Blu-rays can either be stored in a folder structure or as an iso file + // => to find all matches we need to check both: VideoType and IsoType + // alternatively, we could provide specific IsoType filters var videoTypeBs = filter.VideoTypes.Select(vt => $"\"VideoType\":\"{vt}\"").ToArray(); - Expression<Func<BaseItemEntity, bool>> hasVideoType = e => videoTypeBs.Any(f => e.Data!.Contains(f)); + var isoTypeBs = filter.VideoTypes.Select(vt => $"\"IsoType\":\"{vt}\"").ToArray(); + Expression<Func<BaseItemEntity, bool>> hasVideoType = e => videoTypeBs.Any(f => e.Data!.Contains(f)) || isoTypeBs.Any(f => e.Data!.Contains(f)); baseQuery = baseQuery.WhereItemOrDescendantMatches(context, hasVideoType); } 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/ChapterRepository.cs b/Jellyfin.Server.Implementations/Item/ChapterRepository.cs index 98700f3224..f7d76517e1 100644 --- a/Jellyfin.Server.Implementations/Item/ChapterRepository.cs +++ b/Jellyfin.Server.Implementations/Item/ChapterRepository.cs @@ -55,6 +55,7 @@ public class ChapterRepository : IChapterRepository { using var context = _dbProvider.CreateDbContext(); return context.Chapters.AsNoTracking().Where(e => e.ItemId.Equals(baseItemId)) + .OrderBy(e => e.StartPositionTicks) .Select(e => new { chapter = e, @@ -69,18 +70,16 @@ public class ChapterRepository : IChapterRepository public void SaveChapters(Guid itemId, IReadOnlyList<ChapterInfo> chapters) { using var context = _dbProvider.CreateDbContext(); - using (var transaction = context.Database.BeginTransaction()) + using var transaction = context.Database.BeginTransaction(); + context.Chapters.Where(e => e.ItemId.Equals(itemId)).ExecuteDelete(); + for (var i = 0; i < chapters.Count; i++) { - context.Chapters.Where(e => e.ItemId.Equals(itemId)).ExecuteDelete(); - for (var i = 0; i < chapters.Count; i++) - { - var chapter = chapters[i]; - context.Chapters.Add(Map(chapter, i, itemId)); - } - - context.SaveChanges(); - transaction.Commit(); + var chapter = chapters[i]; + context.Chapters.Add(Map(chapter, i, itemId)); } + + context.SaveChanges(); + transaction.Commit(); } /// <inheritdoc /> 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/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs index 415510b2f4..5e5ce320a5 100644 --- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs +++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs @@ -1,4 +1,6 @@ #pragma warning disable RS0030 // Do not use banned APIs +#pragma warning disable CA1304 // Specify CultureInfo +#pragma warning disable CA1311 // Specify a culture or use an invariant version using System; using System.Collections.Generic; @@ -62,17 +64,19 @@ public class LinkedChildrenService : ILinkedChildrenService { using var dbContext = _dbProvider.CreateDbContext(); + var lowerNames = artistNames.Select(n => n.ToLowerInvariant()).ToArray(); var artists = dbContext.BaseItems .AsNoTracking() .Where(e => e.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]!) - .Where(e => artistNames.Contains(e.Name)) + .Where(e => lowerNames.Contains(e.Name!.ToLower())) .ToArray(); var lookup = artists - .GroupBy(e => e.Name!) + .GroupBy(e => e.Name!, StringComparer.OrdinalIgnoreCase) .ToDictionary( g => g.Key, - g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray()); + g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray(), + StringComparer.OrdinalIgnoreCase); var result = new Dictionary<string, MusicArtist[]>(artistNames.Count); foreach (var name in artistNames) @@ -87,14 +91,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/> diff --git a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs index dd0446f49a..7fa33c8639 100644 --- a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs +++ b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs @@ -55,6 +55,17 @@ public class MediaStreamRepository : IMediaStreamRepository return TranslateQuery(context.MediaStreamInfos.AsNoTracking(), filter).AsEnumerable().Select(Map).ToArray(); } + /// <inheritdoc /> + public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType) + { + using var context = _dbProvider.CreateDbContext(); + return context.MediaStreamInfos + .Where(e => e.StreamType == (MediaStreamTypeEntity)mediaStreamType) + .Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined + .Distinct() + .ToArray(); + } + private string? GetPathToSave(string? path) { if (path is null) diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs index ada86c8b87..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, @@ -48,9 +55,9 @@ public static class OrderMapper (ItemSortBy.SeriesSortName, _) => e => e.SeriesName, (ItemSortBy.Album, _) => e => e.Album, (ItemSortBy.DateCreated, _) => e => e.DateCreated, - (ItemSortBy.PremiereDate, _) => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)), + (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.CleanName, + (ItemSortBy.Name, _) => e => e.SortName, (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 6cc9729bbe..eb87b525fe 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -46,9 +46,10 @@ 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. + // 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) + .GroupBy(e => e.Name.ToLower()) .Select(g => g.Min(e => e.Id)); dbQuery = context.Peoples.AsNoTracking() .Where(p => representativeIds.Contains(p.Id)) @@ -102,24 +103,23 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I person.Role = person.Role?.Trim() ?? string.Empty; } - // multiple metadata providers can provide the _same_ person - people = people.DistinctBy(e => e.Name + "-" + e.Type).ToArray(); - var personKeys = people.Select(e => e.Name + "-" + e.Type).ToArray(); + // 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(); using var context = _dbProvider.CreateDbContext(); using var transaction = context.Database.BeginTransaction(); var existingPersons = context.Peoples.Select(e => new - { - item = e, - SelectionKey = e.Name + "-" + 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 => e.Type is not PersonKind.Artist && e.Type is not PersonKind.AlbumArtist) - .Where(e => !existingPersons.Any(f => f.Name == e.Name && f.PersonType == e.Type.ToString())) + .Where(e => !existingPersons.Any(f => string.Equals(f.Name, e.Name, StringComparison.OrdinalIgnoreCase) && f.PersonType == e.Type.ToString())) .Select(Map); context.Peoples.AddRange(toAdd); context.SaveChanges(); @@ -132,13 +132,8 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I foreach (var person in people) { - if (person.Type == PersonKind.Artist || person.Type == PersonKind.AlbumArtist) - { - continue; - } - - var entityPerson = personsEntities.First(e => e.Name == person.Name && e.PersonType == person.Type.ToString()); - var existingMap = existingMaps.FirstOrDefault(e => e.People.Name == person.Name && e.People.PersonType == person.Type.ToString() && e.Role == person.Role); + 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) { context.PeopleBaseItemMap.Add(new PeopleBaseItemMap() @@ -170,6 +165,42 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I transaction.Commit(); } + /// <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; + } + private PersonInfo Map(People people) { var mapping = people.BaseItems?.FirstOrDefault(); @@ -244,7 +275,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)) |
