mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-05-03 23:36:38 +01:00
540 lines
22 KiB
C#
540 lines
22 KiB
C#
#pragma warning disable RS0030 // Do not use banned APIs
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Linq.Expressions;
|
|
using System.Threading.Tasks;
|
|
using Jellyfin.Data.Enums;
|
|
using Jellyfin.Database.Implementations;
|
|
using Jellyfin.Database.Implementations.Entities;
|
|
using Jellyfin.Extensions;
|
|
using MediaBrowser.Controller.Entities;
|
|
using MediaBrowser.Model.Querying;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
|
|
|
|
namespace Jellyfin.Server.Implementations.Item;
|
|
|
|
public sealed partial class BaseItemRepository
|
|
{
|
|
/// <inheritdoc />
|
|
public IReadOnlyList<Guid> GetItemIdsList(InternalItemsQuery filter)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(filter);
|
|
PrepareFilterQuery(filter);
|
|
|
|
using var context = _dbProvider.CreateDbContext();
|
|
return ApplyQueryFilter(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, filter).Select(e => e.Id).ToArray();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public QueryResult<BaseItemDto> GetItems(InternalItemsQuery filter)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(filter);
|
|
if (!filter.EnableTotalRecordCount || (!filter.Limit.HasValue && (filter.StartIndex ?? 0) == 0))
|
|
{
|
|
var returnList = GetItemList(filter);
|
|
return new QueryResult<BaseItemDto>(
|
|
filter.StartIndex,
|
|
returnList.Count,
|
|
returnList);
|
|
}
|
|
|
|
PrepareFilterQuery(filter);
|
|
var result = new QueryResult<BaseItemDto>();
|
|
|
|
using var context = _dbProvider.CreateDbContext();
|
|
|
|
IQueryable<BaseItemEntity> dbQuery = PrepareItemQuery(context, filter);
|
|
|
|
dbQuery = TranslateQuery(dbQuery, context, filter);
|
|
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
|
|
|
|
if (filter.EnableTotalRecordCount)
|
|
{
|
|
result.TotalRecordCount = dbQuery.Count();
|
|
}
|
|
|
|
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
|
dbQuery = ApplyNavigations(dbQuery, filter);
|
|
|
|
result.Items = dbQuery.AsEnumerable().Where(e => e != null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto != null).ToArray()!;
|
|
result.StartIndex = filter.StartIndex ?? 0;
|
|
return result;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyList<BaseItemDto> GetItemList(InternalItemsQuery filter)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(filter);
|
|
PrepareFilterQuery(filter);
|
|
|
|
using var context = _dbProvider.CreateDbContext();
|
|
IQueryable<BaseItemEntity> dbQuery = PrepareItemQuery(context, filter);
|
|
|
|
dbQuery = TranslateQuery(dbQuery, context, filter);
|
|
|
|
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
|
|
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
|
|
|
var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random);
|
|
if (hasRandomSort)
|
|
{
|
|
var orderedIds = dbQuery.AsNoTracking().Select(e => e.Id).ToList();
|
|
if (orderedIds.Count == 0)
|
|
{
|
|
return Array.Empty<BaseItemDto>();
|
|
}
|
|
|
|
var itemsById = ApplyNavigations(context.BaseItems.AsNoTracking().WhereOneOrMany(orderedIds, e => e.Id), filter)
|
|
.AsSplitQuery()
|
|
.AsEnumerable()
|
|
.Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
|
|
.Where(dto => dto != null)
|
|
.ToDictionary(i => i!.Id);
|
|
|
|
return orderedIds.Where(itemsById.ContainsKey).Select(id => itemsById[id]).ToArray()!;
|
|
}
|
|
|
|
dbQuery = ApplyNavigations(dbQuery, filter);
|
|
|
|
return dbQuery.AsEnumerable().Where(e => e != null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto != null).ToArray()!;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public IReadOnlyList<BaseItemDto> GetLatestItemList(InternalItemsQuery filter, CollectionType collectionType)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(filter);
|
|
PrepareFilterQuery(filter);
|
|
|
|
// Early exit if collection type is not supported
|
|
if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var limit = filter.Limit;
|
|
using var context = _dbProvider.CreateDbContext();
|
|
|
|
var baseQuery = PrepareItemQuery(context, filter);
|
|
baseQuery = TranslateQuery(baseQuery, context, filter);
|
|
|
|
if (collectionType == CollectionType.tvshows)
|
|
{
|
|
return GetLatestTvShowItems(context, baseQuery, filter, limit);
|
|
}
|
|
|
|
// Find the top N group keys ordered by most recent DateCreated.
|
|
// Movies group by PresentationUniqueKey (alternate versions like 4K/1080p share a key).
|
|
// Music groups by Album.
|
|
Expression<Func<BaseItemEntity, bool>> groupKeyFilter;
|
|
Expression<Func<BaseItemEntity, string?>> groupKeySelector;
|
|
|
|
if (collectionType is CollectionType.movies)
|
|
{
|
|
groupKeyFilter = e => e.PresentationUniqueKey != null;
|
|
groupKeySelector = e => e.PresentationUniqueKey;
|
|
}
|
|
else
|
|
{
|
|
groupKeyFilter = e => e.Album != null;
|
|
groupKeySelector = e => e.Album;
|
|
}
|
|
|
|
// Group by GroupKey, pick the latest item per group (correlated subquery: ORDER BY DateCreated DESC, Id DESC LIMIT 1),
|
|
// order groups by group max date, take the top N — all in a single SQL statement.
|
|
// ThenByDescending(Id) is the tiebreaker for deterministic ordering when items share a DateCreated.
|
|
var topGroupItems = baseQuery
|
|
.Where(groupKeyFilter)
|
|
.GroupBy(groupKeySelector)
|
|
.Select(g => new
|
|
{
|
|
MaxDate = g.Max(e => e.DateCreated),
|
|
FirstId = g.OrderByDescending(e => e.DateCreated).ThenByDescending(e => e.Id).Select(e => e.Id).First()
|
|
})
|
|
.OrderByDescending(g => g.MaxDate);
|
|
|
|
var firstIdsQuery = filter.Limit.HasValue
|
|
? topGroupItems.Take(filter.Limit.Value).Select(g => g.FirstId)
|
|
: topGroupItems.Select(g => g.FirstId);
|
|
|
|
var firstIds = firstIdsQuery.ToList();
|
|
|
|
// Single bound JSON / array parameter via WhereOneOrMany — keeps SQL small regardless of N.
|
|
var itemsQuery = context.BaseItems.AsNoTracking().WhereOneOrMany(firstIds, e => e.Id);
|
|
itemsQuery = ApplyNavigations(itemsQuery, 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>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// This method implements intelligent container selection for TV shows in the "Latest" section.
|
|
/// Instead of always showing individual episodes, it analyzes recent additions and may return
|
|
/// a Season or Series container when multiple related episodes were recently added.
|
|
/// </para>
|
|
/// <para>
|
|
/// The selection logic is:
|
|
/// <list type="bullet">
|
|
/// <item>If recent episodes span multiple seasons → return the Series</item>
|
|
/// <item>If multiple recent episodes are from one season AND the series has multiple seasons → return the Season</item>
|
|
/// <item>If multiple recent episodes are from one season AND the series has only one season → return the Series</item>
|
|
/// <item>Otherwise → return the most recent Episode</item>
|
|
/// </list>
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="context">The database context.</param>
|
|
/// <param name="baseQuery">The base query with filters already applied.</param>
|
|
/// <param name="filter">The query filter options.</param>
|
|
/// <param name="limit">Maximum number of items to return.</param>
|
|
/// <returns>A list of BaseItemDto representing the latest TV content.</returns>
|
|
private IReadOnlyList<BaseItemDto> GetLatestTvShowItems(JellyfinDbContext context, IQueryable<BaseItemEntity> baseQuery, InternalItemsQuery filter, int? limit)
|
|
{
|
|
// Episodes added within this window are considered "recently added together"
|
|
const double RecentAdditionWindowHours = 24.0;
|
|
|
|
// Step 1: Find the top N series with recently added content, ordered by most recent addition
|
|
var topSeriesWithDates = baseQuery
|
|
.Where(e => e.SeriesName != null)
|
|
.GroupBy(e => e.SeriesName)
|
|
.Select(g => new { SeriesName = g.Key!, MaxDate = g.Max(e => e.DateCreated) })
|
|
.OrderByDescending(g => g.MaxDate);
|
|
|
|
if (limit.HasValue)
|
|
{
|
|
topSeriesWithDates = topSeriesWithDates.Take(limit.Value).OrderByDescending(g => g.MaxDate);
|
|
}
|
|
|
|
// Materialize series names and cutoff to avoid embedding the GroupBy+OrderBy
|
|
// expression tree as a subquery inside the episode query.
|
|
var topSeriesData = topSeriesWithDates
|
|
.Select(g => new { g.SeriesName, g.MaxDate })
|
|
.ToList();
|
|
var topSeriesNames = topSeriesData.Select(g => g.SeriesName).ToList();
|
|
|
|
// Compute a global date cutoff: the oldest series' max date minus the window.
|
|
// Episodes before this cutoff cannot be in any series' "recent additions" window,
|
|
// so we can safely exclude them to avoid loading ancient episodes.
|
|
var globalCutoff = topSeriesData.Count > 0
|
|
? topSeriesData.Min(g => g.MaxDate)?.AddHours(-RecentAdditionWindowHours)
|
|
: null;
|
|
|
|
// Restrict to episodes of the top series, optionally bounded by the global cutoff.
|
|
var episodeQuery = baseQuery.Where(e => e.SeriesName != null && topSeriesNames.Contains(e.SeriesName));
|
|
if (globalCutoff is not null)
|
|
{
|
|
episodeQuery = episodeQuery.Where(e => e.DateCreated >= globalCutoff);
|
|
}
|
|
|
|
// Lightweight projection: only the columns needed for the in-memory analysis below.
|
|
var allEpisodes = episodeQuery
|
|
.OrderByDescending(e => e.DateCreated)
|
|
.ThenByDescending(e => e.Id)
|
|
.Select(e => new { e.Id, e.SeriesName, e.DateCreated, e.SeasonId, e.SeriesId })
|
|
.AsEnumerable();
|
|
|
|
// Collect all season/series IDs we'll need to look up for count information
|
|
var allSeasonIds = new HashSet<Guid>();
|
|
var allSeriesIds = new HashSet<Guid>();
|
|
|
|
// Analysis data for each series: recent episode count, season IDs, and the most recent episode ID
|
|
var analysisData = new List<(
|
|
int RecentEpisodeCount,
|
|
List<Guid> SeasonIds,
|
|
Guid? FirstRecentSeriesId,
|
|
DateTime MaxDate,
|
|
Guid MostRecentEpisodeId)>();
|
|
|
|
// Step 3: Analyze each series to identify recent additions within the time window
|
|
foreach (var group in allEpisodes.GroupBy(e => e.SeriesName))
|
|
{
|
|
var episodes = group.ToList();
|
|
var mostRecentDate = episodes[0].DateCreated ?? DateTime.MinValue;
|
|
var recentCutoff = mostRecentDate.AddHours(-RecentAdditionWindowHours);
|
|
|
|
// Find episodes added within the recent window
|
|
var recentEpisodeCount = 0;
|
|
var seasonIdSet = new HashSet<Guid>();
|
|
Guid? firstRecentSeriesId = null;
|
|
|
|
foreach (var ep in episodes)
|
|
{
|
|
if (ep.DateCreated >= recentCutoff)
|
|
{
|
|
recentEpisodeCount++;
|
|
if (ep.SeasonId.HasValue)
|
|
{
|
|
seasonIdSet.Add(ep.SeasonId.Value);
|
|
}
|
|
|
|
firstRecentSeriesId ??= ep.SeriesId;
|
|
}
|
|
}
|
|
|
|
var seasonIds = seasonIdSet.ToList();
|
|
analysisData.Add((recentEpisodeCount, seasonIds, firstRecentSeriesId, mostRecentDate, episodes[0].Id));
|
|
|
|
// Track all unique season/series IDs for batch lookups
|
|
foreach (var sid in seasonIds)
|
|
{
|
|
allSeasonIds.Add(sid);
|
|
}
|
|
|
|
if (firstRecentSeriesId.HasValue)
|
|
{
|
|
allSeriesIds.Add(firstRecentSeriesId.Value);
|
|
}
|
|
}
|
|
|
|
// Step 4: Batch fetch counts - episodes per season and seasons per series
|
|
// These counts help determine whether to show Season or Series as the container
|
|
var episodeType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
|
|
var seasonType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Season];
|
|
var seasonEpisodeCounts = allSeasonIds.Count > 0
|
|
? context.BaseItems
|
|
.AsNoTracking()
|
|
.Where(e => e.SeasonId.HasValue && allSeasonIds.Contains(e.SeasonId.Value) && e.Type == episodeType)
|
|
.GroupBy(e => e.SeasonId!.Value)
|
|
.Select(g => new { SeasonId = g.Key, Count = g.Count() })
|
|
.ToDictionary(x => x.SeasonId, x => x.Count)
|
|
: [];
|
|
|
|
var seriesSeasonCounts = allSeriesIds.Count > 0
|
|
? context.BaseItems
|
|
.AsNoTracking()
|
|
.Where(e => e.SeriesId.HasValue && allSeriesIds.Contains(e.SeriesId.Value) && e.Type == seasonType)
|
|
.GroupBy(e => e.SeriesId!.Value)
|
|
.Select(g => new { SeriesId = g.Key, Count = g.Count() })
|
|
.ToDictionary(x => x.SeriesId, x => x.Count)
|
|
: [];
|
|
|
|
// Step 5: Apply the container selection logic for each series.
|
|
// For each series, decide which entity best represents the recent additions:
|
|
// - 1 episode added → show the Episode itself
|
|
// - Multiple episodes in 1 season (multi-season series) → show the Season
|
|
// - Multiple episodes in 1 season (single-season series) → show the Series
|
|
// - Episodes across multiple seasons → show the Series
|
|
var entitiesToFetch = new HashSet<Guid>();
|
|
var seriesResults = new List<(Guid? SeasonId, Guid? SeriesId, DateTime MaxDate, Guid MostRecentEpisodeId)>(analysisData.Count);
|
|
|
|
foreach (var (recentEpisodeCount, seasonIds, firstRecentSeriesId, maxDate, mostRecentEpisodeId) in analysisData)
|
|
{
|
|
Guid? seasonId = null;
|
|
Guid? seriesId = null;
|
|
|
|
if (seasonIds.Count == 1)
|
|
{
|
|
// All recent episodes are from a single season
|
|
var sid = seasonIds[0];
|
|
var totalEpisodes = seasonEpisodeCounts.GetValueOrDefault(sid, 0);
|
|
var totalSeasonsInSeries = firstRecentSeriesId.HasValue
|
|
? seriesSeasonCounts.GetValueOrDefault(firstRecentSeriesId.Value, 1)
|
|
: 1;
|
|
|
|
// Check if multiple episodes were added, or if all episodes in the season were added
|
|
var hasMultipleOrAllEpisodes = recentEpisodeCount > 1 || recentEpisodeCount == totalEpisodes;
|
|
|
|
if (totalSeasonsInSeries > 1 && hasMultipleOrAllEpisodes)
|
|
{
|
|
// Multi-season series with bulk additions: show the Season
|
|
seasonId = sid;
|
|
entitiesToFetch.Add(sid);
|
|
}
|
|
else if (hasMultipleOrAllEpisodes && firstRecentSeriesId.HasValue)
|
|
{
|
|
// Single-season series with bulk additions: show the Series
|
|
seriesId = firstRecentSeriesId;
|
|
entitiesToFetch.Add(firstRecentSeriesId.Value);
|
|
}
|
|
|
|
// Otherwise: single episode, will fall through to show the Episode
|
|
}
|
|
else if (seasonIds.Count > 1 && firstRecentSeriesId.HasValue)
|
|
{
|
|
// Recent episodes span multiple seasons: show the Series
|
|
seriesId = firstRecentSeriesId;
|
|
entitiesToFetch.Add(seriesId!.Value);
|
|
}
|
|
|
|
if (seasonId is null && seriesId is null)
|
|
{
|
|
entitiesToFetch.Add(mostRecentEpisodeId);
|
|
}
|
|
|
|
seriesResults.Add((seasonId, seriesId, maxDate, mostRecentEpisodeId));
|
|
}
|
|
|
|
// Step 6: Fetch the Season/Series entities we decided to return
|
|
var entities = entitiesToFetch.Count > 0
|
|
? ApplyNavigations(
|
|
context.BaseItems.AsNoTracking().Where(e => entitiesToFetch.Contains(e.Id)),
|
|
filter)
|
|
.AsSingleQuery()
|
|
.ToDictionary(e => e.Id)
|
|
: [];
|
|
|
|
// Step 7: Build final results, preferring Season > Series > Episode.
|
|
// All needed entities are already fetched in step 6 with navigation properties.
|
|
var results = new List<(BaseItemEntity Entity, DateTime MaxDate)>(seriesResults.Count);
|
|
foreach (var (seasonId, seriesId, maxDate, mostRecentEpisodeId) in seriesResults)
|
|
{
|
|
if (seasonId.HasValue && entities.TryGetValue(seasonId.Value, out var seasonEntity))
|
|
{
|
|
results.Add((seasonEntity, maxDate));
|
|
continue;
|
|
}
|
|
|
|
if (seriesId.HasValue && entities.TryGetValue(seriesId.Value, out var seriesEntity))
|
|
{
|
|
results.Add((seriesEntity, maxDate));
|
|
continue;
|
|
}
|
|
|
|
if (entities.TryGetValue(mostRecentEpisodeId, out var episodeEntity))
|
|
{
|
|
results.Add((episodeEntity, maxDate));
|
|
}
|
|
}
|
|
|
|
var finalResults = results
|
|
.OrderByDescending(r => r.MaxDate)
|
|
.ThenByDescending(r => r.Entity.Id);
|
|
|
|
if (limit.HasValue)
|
|
{
|
|
finalResults = finalResults
|
|
.Take(limit.Value)
|
|
.OrderByDescending(r => r.MaxDate)
|
|
.ThenByDescending(r => r.Entity.Id);
|
|
}
|
|
|
|
return finalResults
|
|
.Select(r => DeserializeBaseItem(r.Entity, filter.SkipDeserialization))
|
|
.Where(dto => dto is not null)
|
|
.ToArray()!;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async Task<bool> ItemExistsAsync(Guid id)
|
|
{
|
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
|
await using (dbContext.ConfigureAwait(false))
|
|
{
|
|
return await dbContext.BaseItems.AnyAsync(f => f.Id == id).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public BaseItemDto? RetrieveItem(Guid id)
|
|
{
|
|
if (id.IsEmpty())
|
|
{
|
|
throw new ArgumentException("Guid can't be empty", nameof(id));
|
|
}
|
|
|
|
using var context = _dbProvider.CreateDbContext();
|
|
var dbQuery = PrepareItemQuery(context, new()
|
|
{
|
|
DtoOptions = new()
|
|
{
|
|
EnableImages = true
|
|
}
|
|
});
|
|
dbQuery = dbQuery.Include(e => e.TrailerTypes)
|
|
.Include(e => e.Provider)
|
|
.Include(e => e.LockedFields)
|
|
.Include(e => e.UserData)
|
|
.Include(e => e.Images)
|
|
.Include(e => e.LinkedChildEntities)
|
|
.AsSingleQuery();
|
|
|
|
var item = dbQuery.FirstOrDefault(e => e.Id == id);
|
|
if (item is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return DeserializeBaseItem(item);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool GetIsPlayed(User user, Guid id, bool recursive)
|
|
{
|
|
using var dbContext = _dbProvider.CreateDbContext();
|
|
|
|
if (recursive)
|
|
{
|
|
var descendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, id);
|
|
|
|
return dbContext.BaseItems
|
|
.Where(e => descendantIds.Contains(e.Id) && !e.IsFolder && !e.IsVirtualItem)
|
|
.All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played));
|
|
}
|
|
|
|
return dbContext.BaseItems.Where(e => e.ParentId == id).All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery filter)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(filter);
|
|
PrepareFilterQuery(filter);
|
|
|
|
using var context = _dbProvider.CreateDbContext();
|
|
var baseQuery = PrepareItemQuery(context, filter);
|
|
baseQuery = TranslateQuery(baseQuery, context, filter);
|
|
|
|
var matchingItemIds = baseQuery.Select(e => e.Id);
|
|
|
|
var years = baseQuery
|
|
.Where(e => e.ProductionYear != null && e.ProductionYear > 0)
|
|
.Select(e => e.ProductionYear!.Value)
|
|
.Distinct()
|
|
.OrderBy(y => y)
|
|
.ToArray();
|
|
|
|
var officialRatings = baseQuery
|
|
.Where(e => e.OfficialRating != null && e.OfficialRating != string.Empty)
|
|
.Select(e => e.OfficialRating!)
|
|
.Distinct()
|
|
.OrderBy(r => r)
|
|
.ToArray();
|
|
|
|
var tags = context.ItemValuesMap
|
|
.Where(ivm => ivm.ItemValue.Type == ItemValueType.Tags)
|
|
.Where(ivm => matchingItemIds.Contains(ivm.ItemId))
|
|
.Select(ivm => ivm.ItemValue)
|
|
.GroupBy(iv => iv.CleanValue)
|
|
.Select(g => g.Min(iv => iv.Value))
|
|
.OrderBy(t => t)
|
|
.ToArray();
|
|
|
|
var genres = context.ItemValuesMap
|
|
.Where(ivm => ivm.ItemValue.Type == ItemValueType.Genre)
|
|
.Where(ivm => matchingItemIds.Contains(ivm.ItemId))
|
|
.Select(ivm => ivm.ItemValue)
|
|
.GroupBy(iv => iv.CleanValue)
|
|
.Select(g => g.Min(iv => iv.Value))
|
|
.OrderBy(g => g)
|
|
.ToArray();
|
|
|
|
return new QueryFiltersLegacy
|
|
{
|
|
Years = years,
|
|
OfficialRatings = officialRatings,
|
|
Tags = tags,
|
|
Genres = genres
|
|
};
|
|
}
|
|
}
|