diff options
Diffstat (limited to 'Jellyfin.Server.Implementations')
8 files changed, 355 insertions, 119 deletions
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs index 8e917f6951..5a41619390 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs @@ -144,11 +144,6 @@ public sealed partial class BaseItemRepository { ArgumentNullException.ThrowIfNull(filter); - if (!filter.Limit.HasValue) - { - filter.EnableTotalRecordCount = false; - } - using var context = _dbProvider.CreateDbContext(); var innerQueryFilter = TranslateQuery(context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)), context, new InternalItemsQuery(filter.User) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs index c7acf72043..c9e08b1b5d 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs @@ -110,7 +110,7 @@ public sealed partial class BaseItemRepository PrepareFilterQuery(filter); // Early exit if collection type is not supported - if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music) + if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music and not CollectionType.unknown) { return []; } @@ -121,30 +121,27 @@ public sealed partial class BaseItemRepository var baseQuery = PrepareItemQuery(context, filter); baseQuery = TranslateQuery(baseQuery, context, filter); - if (collectionType == CollectionType.tvshows) + if (collectionType is CollectionType.tvshows) { return GetLatestTvShowItems(context, baseQuery, filter, limit); } if (collectionType is CollectionType.movies) { - // Pick, per PresentationUniqueKey, the newest item; return the newest `limit` of those. - // Build up until limit by streaming through results and deduplicating on the fly. - var orderedIds = baseQuery - .Where(e => e.PresentationUniqueKey != null) - .OrderByDescending(e => e.DateCreated) - .ThenByDescending(e => e.Id) - .Select(e => new { e.Id, e.PresentationUniqueKey }); - - // DistinctBy and Take are lazy, so enumeration stops as soon as limit distinct keys are read. - var firstIds = orderedIds - .AsEnumerable() - .DistinctBy(row => row.PresentationUniqueKey) - .Select(row => row.Id) + return GetLatestMovieItems(context, baseQuery, filter, limit); + } + + if (collectionType is CollectionType.unknown) + { + var moviesQuery = baseQuery.Where(e => e.SeriesName == null); + var latestMovies = GetLatestMovieItems(context, moviesQuery, filter, limit); + var latestShows = GetLatestTvShowItems(context, baseQuery, filter, limit); + + return latestMovies.Concat(latestShows) + .OrderByDescending(dto => dto.DateCreated) + .ThenByDescending(dto => dto.Id) .Take(limit ?? int.MaxValue) .ToList(); - - return LoadLatestByIds(context, firstIds, filter); } var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]!; @@ -226,6 +223,39 @@ public sealed partial class BaseItemRepository } /// <summary> + /// Gets the latest movies, deduplicated so each movie only appears once. + /// </summary> + /// <param name="context">The database context.</param> + /// <param name="baseQuery">The query to pull movies from, with filters already applied.</param> + /// <param name="filter">The original query filter, used when loading the final items.</param> + /// <param name="limit">How many items to return.</param> + /// <returns>The latest movies, newest first.</returns> + private IReadOnlyList<BaseItemDto> GetLatestMovieItems( + JellyfinDbContext context, + IQueryable<BaseItemEntity> baseQuery, + InternalItemsQuery filter, + int? limit) + { + // Pick, per PresentationUniqueKey, the newest item; return the newest `limit` of those. + // Build up until limit by streaming through results and deduplicating on the fly. + var orderedIds = baseQuery + .Where(e => e.PresentationUniqueKey != null) + .OrderByDescending(e => e.DateCreated) + .ThenByDescending(e => e.Id) + .Select(e => new { e.Id, e.PresentationUniqueKey }); + + // DistinctBy and Take are lazy, so enumeration stops as soon as limit distinct keys are read. + var firstIds = orderedIds + .AsEnumerable() + .DistinctBy(row => row.PresentationUniqueKey) + .Select(row => row.Id) + .Take(limit ?? int.MaxValue) + .ToList(); + + return LoadLatestByIds(context, firstIds, filter); + } + + /// <summary> /// Gets the latest TV show items with smart Season/Series container selection. /// </summary> /// <remarks> diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 8c0a39fe4c..623c1ea0ab 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -35,6 +35,18 @@ public sealed partial class BaseItemRepository // instance across several lambdas, and this filter is combined into a tree more than once. private static Expression<Func<BaseItemEntity, bool>> IsFolderFilter => e => e.IsFolder; + // "und" is the language filters' stand-in for a track that declares no language at all. + private static string NormalizeLanguage(string language) + => string.Equals(language, "und", StringComparison.OrdinalIgnoreCase) ? "und" : language; + + // The primary versions whose alternate version satisfies a dimension bound. Anchored on + // PrimaryVersionId so the filtered index carries it rather than a scan of every item. + private static IQueryable<Guid> VersionsMatchingDimension(JellyfinDbContext context, Expression<Func<BaseItemEntity, bool>> bound) + => context.BaseItems + .Where(v => v.PrimaryVersionId != null) + .Where(bound) + .Select(v => v.PrimaryVersionId!.Value); + /// <inheritdoc /> public IQueryable<BaseItemEntity> TranslateQuery( IQueryable<BaseItemEntity> baseQuery, @@ -70,47 +82,86 @@ public sealed partial class BaseItemRepository include4K = true; } - // Non-folders: check own resolution directly (no subquery). - // Folders (Series, BoxSets): EXISTS check on descendants/linked children. - // Using navigation properties (a.Item, lc.Child) produces efficient - // EXISTS + JOIN instead of nested IN (SELECT ...) subqueries. + // A 4K remux of an SD primary is a version of the same item, so the bucket a caller filters + // on is the best any of the item's versions offers, not just the primary file's. Three sets, + // because a bucket is as much about what the version group does not have as what it does, and + // because an unprobed primary can still be placed by a version that does carry dimensions. + // The filtered PrimaryVersionId index keeps all three to the few items that have versions. + var versionsSd = VersionsMatchingDimension(context, v => v.Width > 0 && v.Width < HDWidth); + var versionsHd = VersionsMatchingDimension(context, v => v.Width >= HDWidth); + var versions4K = VersionsMatchingDimension(context, v => v.Width >= UHDWidth || v.Height >= UHDHeight); + + // Only the SD test needs the Width > 0 guard against a row with no dimensions: such a row + // cannot reach the HD or 4K bound anyway, and EF lowers the HD bucket's negated "not itself + // 4K" guard to CASE WHEN ... THEN 0 ELSE 1, which already reads unknown as not 4K rather + // than propagating a null. Folders (Series, BoxSets) answer on their descendants, bucketed + // exactly as a top-level item is so that the two cannot disagree; the navigation properties + // (a.Item, lc.Child) give EXISTS + JOIN rather than nested IN (SELECT ...). baseQuery = baseQuery.Where(e => - (!e.IsFolder && e.Width > 0 - && ((includeSD && e.Width < HDWidth) - || (includeHD && e.Width >= HDWidth && !(e.Width >= UHDWidth || e.Height >= UHDHeight)) - || (include4K && (e.Width >= UHDWidth || e.Height >= UHDHeight)))) + (!e.IsFolder + && ((includeSD + && ((e.Width > 0 && e.Width < HDWidth) || versionsSd.Contains(e.Id)) + && !versionsHd.Contains(e.Id) + && !versions4K.Contains(e.Id)) + || (includeHD + && (e.Width >= HDWidth || versionsHd.Contains(e.Id)) + && !(e.Width >= UHDWidth || e.Height >= UHDHeight) + && !versions4K.Contains(e.Id)) + || (include4K + && (e.Width >= UHDWidth || e.Height >= UHDHeight || versions4K.Contains(e.Id))))) || (e.IsFolder && (e.Children!.Any(a => - a.Item.Width > 0 - && ((includeSD && a.Item.Width < HDWidth) - || (includeHD && a.Item.Width >= HDWidth && !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight)) - || (include4K && (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight)))) + (includeSD + && ((a.Item.Width > 0 && a.Item.Width < HDWidth) || versionsSd.Contains(a.ItemId)) + && !versionsHd.Contains(a.ItemId) + && !versions4K.Contains(a.ItemId)) + || (includeHD + && (a.Item.Width >= HDWidth || versionsHd.Contains(a.ItemId)) + && !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight) + && !versions4K.Contains(a.ItemId)) + || (include4K + && (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight || versions4K.Contains(a.ItemId)))) || context.LinkedChildren.Any(lc => lc.ParentId == e.Id - && lc.Child!.Width > 0 - && ((includeSD && lc.Child.Width < HDWidth) - || (includeHD && lc.Child.Width >= HDWidth && !(lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight)) - || (include4K && (lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight))))))); - } - + && ((includeSD + && ((lc.Child!.Width > 0 && lc.Child!.Width < HDWidth) || versionsSd.Contains(lc.ChildId)) + && !versionsHd.Contains(lc.ChildId) + && !versions4K.Contains(lc.ChildId)) + || (includeHD + && (lc.Child!.Width >= HDWidth || versionsHd.Contains(lc.ChildId)) + && !(lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight) + && !versions4K.Contains(lc.ChildId)) + || (include4K + && (lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight || versions4K.Contains(lc.ChildId)))))))); + } + + // Same reasoning as the resolution filter: a dimension bound is met if any version meets it. if (minWidth.HasValue) { - baseQuery = baseQuery.Where(e => e.Width >= minWidth); + var versionsWideEnough = VersionsMatchingDimension(context, v => v.Width >= minWidth); + baseQuery = baseQuery.Where(e => e.Width >= minWidth || versionsWideEnough.Contains(e.Id)); } if (filter.MinHeight.HasValue) { - baseQuery = baseQuery.Where(e => e.Height >= filter.MinHeight); + var minHeight = filter.MinHeight; + var versionsTallEnough = VersionsMatchingDimension(context, v => v.Height >= minHeight); + baseQuery = baseQuery.Where(e => e.Height >= minHeight || versionsTallEnough.Contains(e.Id)); } + // An upper bound inverts that: it is met only if no version breaches it, since the item's + // resolution is the best its version group offers. if (maxWidth.HasValue) { - baseQuery = baseQuery.Where(e => e.Width <= maxWidth); + var versionsTooWide = VersionsMatchingDimension(context, v => v.Width > maxWidth); + baseQuery = baseQuery.Where(e => e.Width <= maxWidth && !versionsTooWide.Contains(e.Id)); } if (filter.MaxHeight.HasValue) { - baseQuery = baseQuery.Where(e => e.Height <= filter.MaxHeight); + var maxHeight = filter.MaxHeight; + var versionsTooTall = VersionsMatchingDimension(context, v => v.Height > maxHeight); + baseQuery = baseQuery.Where(e => e.Height <= maxHeight && !versionsTooTall.Contains(e.Id)); } if (filter.IsLocked.HasValue) @@ -356,7 +407,7 @@ public sealed partial class BaseItemRepository } else { - baseQuery = baseQuery.Where(e => e.StartDate > now && e.EndDate < now); + baseQuery = baseQuery.Where(e => e.StartDate > now || e.EndDate < now); } } @@ -370,14 +421,16 @@ public sealed partial class BaseItemRepository p => p.Name, (b, p) => p.Id); + var personTypes = filter.PersonTypes; baseQuery = baseQuery .Where(e => context.PeopleBaseItemMap - .Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId))); + .Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId) && (personTypes.Length == 0 || personTypes.Contains(m.People.PersonType)))); } if (!string.IsNullOrWhiteSpace(filter.Person)) { - baseQuery = baseQuery.Where(e => e.Peoples!.Any(f => f.People.Name == filter.Person)); + var personTypes = filter.PersonTypes; + baseQuery = baseQuery.Where(e => e.Peoples!.Any(f => f.People.Name == filter.Person && (personTypes.Length == 0 || personTypes.Contains(f.People.PersonType)))); } if (!string.IsNullOrWhiteSpace(filter.ExternalSeriesId)) @@ -555,7 +608,7 @@ public sealed partial class BaseItemRepository if (filter.ArtistIds.Length > 0) { - baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds); + baseQuery = baseQuery.WhereReferencedItem(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds); } if (filter.AlbumArtistIds.Length > 0) @@ -586,12 +639,12 @@ public sealed partial class BaseItemRepository if (filter.ExcludeArtistIds.Length > 0) { - baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true); + baseQuery = baseQuery.WhereReferencedItem(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true); } if (filter.GenreIds.Count > 0) { - baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds.ToArray()); + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds); } if (filter.Genres.Count > 0) @@ -617,7 +670,7 @@ public sealed partial class BaseItemRepository if (filter.StudioIds.Length > 0) { - baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds.ToArray()); + baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds); } if (filter.OfficialRatings.Length > 0) @@ -759,104 +812,144 @@ public sealed partial class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage)) { - var lang = filter.HasNoAudioTrackWithLanguage; - var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang)); + var lang = NormalizeLanguage(filter.HasNoAudioTrackWithLanguage); + var undetermined = string.Equals(lang, "und", StringComparison.Ordinal); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang); + // A track only an alternate version carries still belongs to the item a caller sees, so the + // item's own streams alone do not decide this. Same for every stream filter below. + var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); baseQuery = baseQuery .Where(e => - (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio && ms.Language == lang)) + (!e.IsFolder + && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio + && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language)))) + && !versionsWithAudio.Contains(e.Id)) || (e.IsFolder && !foldersWithAudio.Contains(e.Id))); } if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage)) { - var lang = filter.HasNoInternalSubtitleTrackWithLanguage; - var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false)); + var lang = NormalizeLanguage(filter.HasNoInternalSubtitleTrackWithLanguage); + var undetermined = string.Equals(lang, "und", StringComparison.Ordinal); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false); + var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); baseQuery = baseQuery .Where(e => - (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal && ms.Language == lang)) + (!e.IsFolder + && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal + && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language)))) + && !versionsWithSubtitles.Contains(e.Id)) || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); } if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage)) { - var lang = filter.HasNoExternalSubtitleTrackWithLanguage; - var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true)); + var lang = NormalizeLanguage(filter.HasNoExternalSubtitleTrackWithLanguage); + var undetermined = string.Equals(lang, "und", StringComparison.Ordinal); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true); + var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); baseQuery = baseQuery .Where(e => - (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal && ms.Language == lang)) + (!e.IsFolder + && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal + && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language)))) + && !versionsWithSubtitles.Contains(e.Id)) || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); } if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage)) { - var lang = filter.HasNoSubtitleTrackWithLanguage; - var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang)); + var lang = NormalizeLanguage(filter.HasNoSubtitleTrackWithLanguage); + var undetermined = string.Equals(lang, "und", StringComparison.Ordinal); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang); + var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); baseQuery = baseQuery .Where(e => - (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.Language == lang)) + (!e.IsFolder + && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle + && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language)))) + && !versionsWithSubtitles.Contains(e.Id)) || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); } if (filter.HasSubtitles.HasValue) { var hasSubtitles = filter.HasSubtitles.Value; - var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasSubtitles()); + var criteria = new HasSubtitles(); + var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); if (hasSubtitles) { baseQuery = baseQuery .Where(e => - (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)) + (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle) + || versionsWithSubtitles.Contains(e.Id))) || (e.IsFolder && foldersWithSubtitles.Contains(e.Id))); } else { baseQuery = baseQuery .Where(e => - (!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)) + (!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle) + && !versionsWithSubtitles.Contains(e.Id)) || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); } } if (filter.SubtitleLanguages.Count > 0) { - var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages)); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages); + var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); baseQuery = baseQuery .Where(e => - (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle - && (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))) + (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle + && (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))) + || versionsWithSubtitles.Contains(e.Id))) || (e.IsFolder && foldersWithSubtitles.Contains(e.Id))); } if (filter.AudioLanguages.Count > 0) { - var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages)); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages); + var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); baseQuery = baseQuery .Where(e => - (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio - && (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))) + (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio + && (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))) + || versionsWithAudio.Contains(e.Id))) || (e.IsFolder && foldersWithAudio.Contains(e.Id))); } if (filter.HasChapterImages.HasValue) { var hasChapterImages = filter.HasChapterImages.Value; - var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, new HasChapterImages()); + var criteria = new HasChapterImages(); + var versionsWithChapterImages = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); if (hasChapterImages) { baseQuery = baseQuery .Where(e => - (!e.IsFolder && e.Chapters!.Any(f => f.ImagePath != null)) + (!e.IsFolder && (e.Chapters!.Any(f => f.ImagePath != null) + || versionsWithChapterImages.Contains(e.Id))) || (e.IsFolder && foldersWithChapterImages.Contains(e.Id))); } else { baseQuery = baseQuery .Where(e => - (!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null)) + (!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null) + && !versionsWithChapterImages.Contains(e.Id)) || (e.IsFolder && !foldersWithChapterImages.Contains(e.Id))); } } @@ -963,17 +1056,6 @@ public sealed partial class BaseItemRepository 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 => includeAny.Contains(f))); - } - } - if (filter.HasImdbId.HasValue) { baseQuery = filter.HasImdbId.Value diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs index fd683fb57e..a320ba89d1 100644 --- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -318,13 +318,14 @@ public class ItemCountService : IItemCountService var parentIdsArray = parentIds.ToArray(); var hierarchicalCounts = dbContext.BaseItems - .Where(b => b.ParentId.HasValue && parentIdsArray.Contains(b.ParentId.Value)) + .Where(b => b.ParentId.HasValue) + .WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value) .GroupBy(b => b.ParentId!.Value) .Select(g => new { ParentId = g.Key, Count = g.Count() }) .ToDictionary(x => x.ParentId, x => x.Count); var linkedCounts = dbContext.LinkedChildren - .Where(lc => parentIdsArray.Contains(lc.ParentId)) + .WhereOneOrMany(parentIdsArray, lc => lc.ParentId) .GroupBy(lc => lc.ParentId) .Select(g => new { ParentId = g.Key, Count = g.Count() }) .ToDictionary(x => x.ParentId, x => x.Count); diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs index 827c766449..efff3457a3 100644 --- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs @@ -257,23 +257,19 @@ public class ItemPersistenceService : IItemPersistenceService using var transaction = context.Database.BeginTransaction(); var ids = tuples.Select(f => f.Item.Id).ToArray(); - var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray(); + var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToHashSet(); foreach (var item in tuples) { var entity = BaseItemMapper.Map(item.Item, _appHost); entity.TopParentId = item.TopParent?.Id; - if (!existingItems.Any(e => e == entity.Id)) + if (!existingItems.Contains(entity.Id)) { context.BaseItems.Add(entity); } else { - context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete(); - context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete(); - context.BaseItemMetadataFields.Where(e => e.ItemId == entity.Id).ExecuteDelete(); - if (entity.Images is { Count: > 0 }) { context.BaseItemImageInfos.AddRange(entity.Images); @@ -314,9 +310,11 @@ public class ItemPersistenceService : IItemPersistenceService }).ToArray(); context.ItemValues.AddRange(missingItemValues); - var itemValuesStore = existingValues.Concat(missingItemValues).ToArray(); + var itemValuesStore = existingValues + .Concat(missingItemValues) + .ToDictionary(e => (e.Type, e.Value)); var valueMap = itemValueMaps - .Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore.First(g => g.Value == e.Value && g.Type == e.MagicNumber)).DistinctBy(e => e.ItemValueId).ToArray())) + .Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore[(e.MagicNumber, e.Value)]).DistinctBy(e => e.ItemValueId).ToArray())) .ToArray(); var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList(); @@ -401,6 +399,15 @@ public class ItemPersistenceService : IItemPersistenceService } } + // Owned rows of updated items are rewritten wholesale; cleared in one statement per table. + if (existingItems.Count > 0) + { + var updatedIds = existingItems.ToArray(); + context.BaseItemProviders.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete(); + context.BaseItemImageInfos.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete(); + context.BaseItemMetadataFields.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete(); + } + context.SaveChanges(); var folderIds = tuples @@ -531,7 +538,7 @@ public class ItemPersistenceService : IItemPersistenceService var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList(); var existingChildIds = childIdsToCheck.Count > 0 ? context.BaseItems - .Where(e => childIdsToCheck.Contains(e.Id)) + .WhereOneOrMany(childIdsToCheck, e => e.Id) .Select(e => e.Id) .ToHashSet() : []; diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs index 5f1d9bf87a..de112d7aa4 100644 --- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs +++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Persistence; using Microsoft.EntityFrameworkCore; @@ -60,30 +61,50 @@ public class LinkedChildrenService : ILinkedChildrenService } /// <inheritdoc/> + public IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds) + { + if (itemIds.Count == 0) + { + return new HashSet<Guid>(); + } + + using var dbContext = _dbProvider.CreateDbContext(); + + return dbContext.LinkedChildren + .Where(lc => lc.ChildType == DbLinkedChildType.LocalAlternateVersion + || lc.ChildType == DbLinkedChildType.LinkedAlternateVersion) + .WhereOneOrMany(itemIds, lc => lc.ParentId) + .Select(lc => lc.ParentId) + .Distinct() + .ToHashSet(); + } + + /// <inheritdoc/> public IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames) { using var dbContext = _dbProvider.CreateDbContext(); - var lowerNames = artistNames.Select(n => n.ToLowerInvariant()).ToArray(); + var cleanNames = artistNames.Select(n => (Original: n, Clean: n.GetCleanValue())).ToArray(); + var cleanValues = cleanNames.Select(x => x.Clean).ToArray(); + var artists = dbContext.BaseItems .AsNoTracking() .Where(e => e.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]!) - .Where(e => lowerNames.Contains(e.Name!.ToLower())) + .Where(e => cleanValues.Contains(e.CleanName)) .ToArray(); var lookup = artists - .GroupBy(e => e.Name!, StringComparer.OrdinalIgnoreCase) + .GroupBy(e => e.CleanName!) .ToDictionary( g => g.Key, - g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray(), - StringComparer.OrdinalIgnoreCase); + g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray()); - var result = new Dictionary<string, MusicArtist[]>(artistNames.Count); - foreach (var name in artistNames) + var result = new Dictionary<string, MusicArtist[]>(cleanNames.Length); + foreach (var (original, clean) in cleanNames) { - if (lookup.TryGetValue(name, out var artistArray)) + if (lookup.TryGetValue(clean, out var artistArray)) { - result[name] = artistArray; + result[original] = artistArray; } } diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index 05c8bffd66..aaa363b046 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -236,6 +236,53 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I return result; } + /// <inheritdoc/> + public IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds) + { + using var context = _dbProvider.CreateDbContext(); + var rows = context.PeopleBaseItemMap + .AsNoTracking() + .Where(m => itemIds.Contains(m.ItemId)) + .OrderBy(m => m.ListOrder) + .Select(m => new + { + m.ItemId, + m.Role, + m.SortOrder, + m.People.Id, + m.People.Name, + m.People.PersonType + }) + .ToList(); + + var result = new Dictionary<Guid, IReadOnlyList<PersonInfo>>(); + foreach (var group in rows.GroupBy(r => r.ItemId)) + { + var people = new List<PersonInfo>(); + foreach (var row in group) + { + var personInfo = new PersonInfo + { + ItemId = row.ItemId, + Id = row.Id, + Name = row.Name, + Role = row.Role, + SortOrder = row.SortOrder + }; + if (Enum.TryParse<PersonKind>(row.PersonType, out var kind)) + { + personInfo.Type = kind; + } + + people.Add(personInfo); + } + + result[group.Key] = people; + } + + return result; + } + private IEnumerable<PersonInfo> MapCredits(People people) { var mappings = people.BaseItems; @@ -304,7 +351,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I if (!filter.ItemId.IsEmpty()) { - query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.ItemId))); + var itemId = filter.ItemId; + query = query.Where(e => context.PeopleBaseItemMap + .Where(m => m.ItemId.Equals(itemId)) + .Select(m => m.PeopleId) + .Contains(e.Id)); } if (filter.ParentId != null) @@ -314,7 +365,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I if (!filter.AppearsInItemId.IsEmpty()) { - query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.AppearsInItemId))); + var appearsInItemId = filter.AppearsInItemId; + query = query.Where(e => context.PeopleBaseItemMap + .Where(m => m.ItemId.Equals(appearsInItemId)) + .Select(m => m.PeopleId) + .Contains(e.Id)); } var queryPersonTypes = filter.PersonTypes.Where(IsValidPersonType).ToList(); diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 81408d9aa8..fea6084267 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -225,17 +225,8 @@ namespace Jellyfin.Server.Implementations.Users ?? throw new ResourceNotFoundException(nameof(user.Id)); dbContext.Entry(dbUser).CurrentValues.SetValues(user); - dbUser.Permissions.Clear(); - foreach (var permission in user.Permissions) - { - dbUser.Permissions.Add(new Permission(permission.Kind, permission.Value)); - } - - dbUser.Preferences.Clear(); - foreach (var preference in user.Preferences) - { - dbUser.Preferences.Add(new Preference(preference.Kind, preference.Value)); - } + SyncPermissions(dbUser, user.Permissions); + SyncPreferences(dbUser, user.Preferences); dbUser.AccessSchedules.Clear(); foreach (var accessSchedule in user.AccessSchedules) @@ -269,6 +260,60 @@ namespace Jellyfin.Server.Implementations.Users } } + private static void SyncPermissions(User dbUser, ICollection<Permission> source) + { + var incoming = new Dictionary<PermissionKind, bool>(); + foreach (var permission in source) + { + incoming[permission.Kind] = permission.Value; + } + + foreach (var existing in dbUser.Permissions) + { + if (incoming.Remove(existing.Kind, out var value)) + { + // EF only marks the row modified if the value actually differs, so an update that + // touches nothing but the user row - a session activity stamp - writes no children. + existing.Value = value; + } + else + { + dbUser.Permissions.Remove(existing); + } + } + + foreach (var (kind, value) in incoming) + { + dbUser.Permissions.Add(new Permission(kind, value)); + } + } + + private static void SyncPreferences(User dbUser, ICollection<Preference> source) + { + var incoming = new Dictionary<PreferenceKind, string>(); + foreach (var preference in source) + { + incoming[preference.Kind] = preference.Value; + } + + foreach (var existing in dbUser.Preferences) + { + if (incoming.Remove(existing.Kind, out var value)) + { + existing.Value = value; + } + else + { + dbUser.Preferences.Remove(existing); + } + } + + foreach (var (kind, value) in incoming) + { + dbUser.Preferences.Add(new Preference(kind, value)); + } + } + internal async Task<User> CreateUserInternalAsync(string name, JellyfinDbContext dbContext) { // TODO: Remove after user item data is migrated. |
