mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-06-08 00:39:25 +01:00
Implement search providers
This commit is contained in:
@@ -25,6 +25,7 @@ using Emby.Server.Implementations.Dto;
|
||||
using Emby.Server.Implementations.HttpServer.Security;
|
||||
using Emby.Server.Implementations.IO;
|
||||
using Emby.Server.Implementations.Library;
|
||||
using Emby.Server.Implementations.Library.Search;
|
||||
using Emby.Server.Implementations.Localization;
|
||||
using Emby.Server.Implementations.Playlists;
|
||||
using Emby.Server.Implementations.Plugins;
|
||||
@@ -537,7 +538,8 @@ namespace Emby.Server.Implementations
|
||||
|
||||
serviceCollection.AddSingleton<ILibraryMonitor, LibraryMonitor>();
|
||||
|
||||
serviceCollection.AddSingleton<ISearchEngine, SearchEngine>();
|
||||
serviceCollection.AddSingleton<ISearchManager, SearchManager>();
|
||||
serviceCollection.AddSingleton<ISearchProvider, SqlSearchProvider>();
|
||||
|
||||
serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>();
|
||||
|
||||
@@ -694,6 +696,7 @@ namespace Emby.Server.Implementations
|
||||
GetExports<IExternalUrlProvider>());
|
||||
|
||||
Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>());
|
||||
Resolve<ISearchManager>().AddParts(GetExports<ISearchProvider>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
443
Emby.Server.Implementations/Library/Search/SearchManager.cs
Normal file
443
Emby.Server.Implementations/Library/Search/SearchManager.cs
Normal file
@@ -0,0 +1,443 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using MediaBrowser.Model.Search;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Manages search providers and orchestrates search operations.
|
||||
/// </summary>
|
||||
public class SearchManager : ISearchManager
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IItemQueryHelpers _queryHelpers;
|
||||
private readonly ILogger<SearchManager> _logger;
|
||||
private IExternalSearchProvider[] _externalProviders = [];
|
||||
private IInternalSearchProvider[] _internalProviders = [];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SearchManager"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="dbProvider">The database context factory.</param>
|
||||
/// <param name="queryHelpers">The shared item query helpers.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public SearchManager(
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||
IItemQueryHelpers queryHelpers,
|
||||
ILogger<SearchManager> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_dbProvider = dbProvider;
|
||||
_queryHelpers = queryHelpers;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void AddParts(IEnumerable<ISearchProvider> providers)
|
||||
{
|
||||
var allProviders = providers.OrderBy(p => p.Priority).ToArray();
|
||||
|
||||
_externalProviders = allProviders.OfType<IExternalSearchProvider>().ToArray();
|
||||
_internalProviders = allProviders.OfType<IInternalSearchProvider>().ToArray();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Registered {ExternalCount} external search providers: {ExternalProviders}. Fallback providers: {FallbackProviders}",
|
||||
_externalProviders.Length,
|
||||
string.Join(", ", _externalProviders.Select(p => $"{p.Name} (priority {p.Priority})")),
|
||||
string.Join(", ", _internalProviders.Select(p => $"{p.Name} (priority {p.Priority})")));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyList<ISearchProvider> GetProviders()
|
||||
{
|
||||
return [.. _externalProviders, .. _internalProviders];
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<SearchResult>> GetSearchResultsAsync(
|
||||
SearchProviderQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
|
||||
|
||||
var searchTerm = query.SearchTerm.Trim().RemoveDiacritics();
|
||||
|
||||
var results = await CollectFromProvidersAsync(_externalProviders, query, searchTerm, cancellationToken).ConfigureAwait(false);
|
||||
if (results.Count == 0 && _internalProviders.Length > 0)
|
||||
{
|
||||
_logger.LogDebug("No results from external providers, falling back to internal providers");
|
||||
results = await CollectFromProvidersAsync(_internalProviders, query, searchTerm, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// External providers don't know about user permissions, so they may return IDs from
|
||||
// hidden libraries or items the user is otherwise blocked from. Filter the candidate
|
||||
// set to only items this user can access (top-parent libraries, parental rating,
|
||||
// blocked/allowed tags, owned-item rules) before returning. The Items controller's
|
||||
// second roundtrip via folder.GetItems applies most of these again, but it does not
|
||||
// restrict by TopParentIds when ItemIds is set, leaving a gap that this closes.
|
||||
if (results.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty())
|
||||
{
|
||||
var user = _userManager.GetUserById(query.UserId.Value);
|
||||
if (user is not null)
|
||||
{
|
||||
results = await FilterByUserAccessAsync(results, user, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
|
||||
IReadOnlyList<SearchResult> candidates,
|
||||
User user,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// SetUser populates parental rating + blocked/allowed tags. ConfigureUserAccess populates
|
||||
// TopParentIds for the user's accessible libraries — we call it before assigning ItemIds
|
||||
// because LibraryManager.AddUserToQuery skips TopParentIds when ItemIds is non-empty.
|
||||
var accessFilter = new InternalItemsQuery(user);
|
||||
_libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||
|
||||
var candidateIds = new Guid[candidates.Count];
|
||||
for (var i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
candidateIds[i] = candidates[i].ItemId;
|
||||
}
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var baseQuery = dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(e => candidateIds.Contains(e.Id));
|
||||
|
||||
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
|
||||
|
||||
var allowedIds = await baseQuery
|
||||
.Select(e => e.Id)
|
||||
.ToHashSetAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (allowedIds.Count == candidates.Count)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var filtered = new List<SearchResult>(allowedIds.Count);
|
||||
foreach (var c in candidates)
|
||||
{
|
||||
if (allowedIds.Contains(c.ItemId))
|
||||
{
|
||||
filtered.Add(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (filtered.Count < candidates.Count)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Dropped {Dropped} of {Total} search candidates due to user access filtering",
|
||||
candidates.Count - filtered.Count,
|
||||
candidates.Count);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<QueryResult<SearchHintInfo>> GetSearchHintsAsync(SearchQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
|
||||
|
||||
var providerQuery = BuildProviderQuery(query);
|
||||
var candidates = await GetSearchResultsAsync(providerQuery, cancellationToken).ConfigureAwait(false);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return new QueryResult<SearchHintInfo>();
|
||||
}
|
||||
|
||||
var candidateScores = BuildScoreLookup(candidates);
|
||||
var user = !query.UserId.IsEmpty() ? _userManager.GetUserById(query.UserId) : null;
|
||||
|
||||
var excludeItemTypes = BuildExcludeItemTypes(query);
|
||||
var includeItemTypes = BuildIncludeItemTypes(query);
|
||||
|
||||
var internalQuery = new InternalItemsQuery(user)
|
||||
{
|
||||
ItemIds = candidateScores.Keys.ToArray(),
|
||||
ExcludeItemTypes = excludeItemTypes.ToArray(),
|
||||
IncludeItemTypes = includeItemTypes.Count > 0 ? includeItemTypes.ToArray() : [],
|
||||
MediaTypes = query.MediaTypes.ToArray(),
|
||||
IncludeItemsByName = !query.ParentId.HasValue,
|
||||
ParentId = query.ParentId ?? Guid.Empty,
|
||||
Recursive = true,
|
||||
IsKids = query.IsKids,
|
||||
IsMovie = query.IsMovie,
|
||||
IsNews = query.IsNews,
|
||||
IsSeries = query.IsSeries,
|
||||
IsSports = query.IsSports,
|
||||
DtoOptions = new DtoOptions
|
||||
{
|
||||
Fields =
|
||||
[
|
||||
ItemFields.AirTime,
|
||||
ItemFields.DateCreated,
|
||||
ItemFields.ChannelInfo,
|
||||
ItemFields.ParentId
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// MusicArtist items are "ItemsByName" entities - virtual items that aggregate content by artist name
|
||||
// rather than being stored as regular library items. They require special handling:
|
||||
// 1. Convert ParentId to AncestorIds (to filter by library folder)
|
||||
// 2. Set IncludeItemsByName = true (to include these virtual items in results)
|
||||
// 3. Clear IncludeItemTypes (GetAllArtists handles type filtering internally)
|
||||
// 4. Use GetAllArtists() instead of GetItemList() to query the artist index
|
||||
IReadOnlyList<BaseItem> items;
|
||||
if (internalQuery.IncludeItemTypes.Length == 1 && internalQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist)
|
||||
{
|
||||
if (!internalQuery.ParentId.IsEmpty())
|
||||
{
|
||||
internalQuery.AncestorIds = [internalQuery.ParentId];
|
||||
internalQuery.ParentId = Guid.Empty;
|
||||
}
|
||||
|
||||
internalQuery.IncludeItemsByName = true;
|
||||
internalQuery.IncludeItemTypes = [];
|
||||
items = _libraryManager.GetAllArtists(internalQuery).Items.Select(i => i.Item).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
items = _libraryManager.GetItemList(internalQuery);
|
||||
}
|
||||
|
||||
var orderedResults = items
|
||||
.Select(item => new SearchHintInfo { Item = item })
|
||||
.OrderByDescending(hint => candidateScores.GetValueOrDefault(hint.Item.Id, 0f))
|
||||
.ToList();
|
||||
|
||||
var totalCount = orderedResults.Count;
|
||||
|
||||
if (query.StartIndex.HasValue)
|
||||
{
|
||||
orderedResults = orderedResults.Skip(query.StartIndex.Value).ToList();
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue)
|
||||
{
|
||||
orderedResults = orderedResults.Take(query.Limit.Value).ToList();
|
||||
}
|
||||
|
||||
return new QueryResult<SearchHintInfo>(query.StartIndex, totalCount, orderedResults);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SearchResult>> CollectFromProvidersAsync(
|
||||
IEnumerable<ISearchProvider> providers,
|
||||
SearchProviderQuery providerQuery,
|
||||
string searchTerm,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bestScores = new Dictionary<Guid, float>();
|
||||
var requestedLimit = providerQuery.Limit ?? 100;
|
||||
|
||||
foreach (var provider in providers.Where(p => p.CanSearch(providerQuery)))
|
||||
{
|
||||
if (bestScores.Count >= requestedLimit || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (provider is IExternalSearchProvider externalProvider)
|
||||
{
|
||||
var count = 0;
|
||||
await foreach (var result in externalProvider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
UpdateBestScore(bestScores, result);
|
||||
count++;
|
||||
if (bestScores.Count >= requestedLimit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"External provider {Provider} returned {Count} candidates for search term '{SearchTerm}'",
|
||||
provider.Name,
|
||||
count,
|
||||
searchTerm);
|
||||
}
|
||||
else
|
||||
{
|
||||
var candidates = await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false);
|
||||
foreach (var result in candidates)
|
||||
{
|
||||
UpdateBestScore(bestScores, result);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'",
|
||||
provider.Name,
|
||||
candidates.Count,
|
||||
searchTerm);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTerm);
|
||||
}
|
||||
}
|
||||
|
||||
return bestScores
|
||||
.Select(kvp => new SearchResult(kvp.Key, kvp.Value))
|
||||
.OrderByDescending(r => r.Score)
|
||||
.Take(requestedLimit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static void UpdateBestScore(Dictionary<Guid, float> bestScores, SearchResult result)
|
||||
{
|
||||
if (!bestScores.TryGetValue(result.ItemId, out var existingScore) || result.Score > existingScore)
|
||||
{
|
||||
bestScores[result.ItemId] = result.Score;
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<Guid, float> BuildScoreLookup(IReadOnlyList<SearchResult> results)
|
||||
{
|
||||
var lookup = new Dictionary<Guid, float>(results.Count);
|
||||
foreach (var result in results)
|
||||
{
|
||||
lookup[result.ItemId] = result.Score;
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
private static SearchProviderQuery BuildProviderQuery(SearchQuery query)
|
||||
{
|
||||
var excludeItemTypes = BuildExcludeItemTypes(query);
|
||||
var includeItemTypes = BuildIncludeItemTypes(query);
|
||||
|
||||
// Remove any excluded types from includes
|
||||
if (includeItemTypes.Count > 0 && excludeItemTypes.Count > 0)
|
||||
{
|
||||
includeItemTypes.RemoveAll(excludeItemTypes.Contains);
|
||||
}
|
||||
|
||||
return new SearchProviderQuery
|
||||
{
|
||||
SearchTerm = query.SearchTerm,
|
||||
UserId = query.UserId.IsEmpty() ? null : query.UserId,
|
||||
IncludeItemTypes = includeItemTypes.ToArray(),
|
||||
ExcludeItemTypes = excludeItemTypes.ToArray(),
|
||||
MediaTypes = query.MediaTypes.ToArray(),
|
||||
Limit = query.Limit,
|
||||
ParentId = query.ParentId
|
||||
};
|
||||
}
|
||||
|
||||
private static List<BaseItemKind> BuildExcludeItemTypes(SearchQuery query)
|
||||
{
|
||||
var excludeItemTypes = query.ExcludeItemTypes.ToList();
|
||||
|
||||
excludeItemTypes.Add(BaseItemKind.Year);
|
||||
excludeItemTypes.Add(BaseItemKind.Folder);
|
||||
excludeItemTypes.Add(BaseItemKind.CollectionFolder);
|
||||
|
||||
if (!query.IncludeGenres)
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Genre);
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre);
|
||||
}
|
||||
|
||||
if (!query.IncludePeople)
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Person);
|
||||
}
|
||||
|
||||
if (!query.IncludeStudios)
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Studio);
|
||||
}
|
||||
|
||||
if (!query.IncludeArtists)
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist);
|
||||
}
|
||||
|
||||
return excludeItemTypes;
|
||||
}
|
||||
|
||||
private static List<BaseItemKind> BuildIncludeItemTypes(SearchQuery query)
|
||||
{
|
||||
var includeItemTypes = query.IncludeItemTypes.ToList();
|
||||
if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Genre)))
|
||||
{
|
||||
if (!query.IncludeMedia)
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.Genre);
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre);
|
||||
}
|
||||
}
|
||||
|
||||
if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Person)))
|
||||
{
|
||||
if (!query.IncludeMedia)
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.Person);
|
||||
}
|
||||
}
|
||||
|
||||
if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Studio)))
|
||||
{
|
||||
if (!query.IncludeMedia)
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.Studio);
|
||||
}
|
||||
}
|
||||
|
||||
if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.MusicArtist)))
|
||||
{
|
||||
if (!query.IncludeMedia)
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist);
|
||||
}
|
||||
}
|
||||
|
||||
return includeItemTypes;
|
||||
}
|
||||
|
||||
private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value)
|
||||
{
|
||||
if (!list.Contains(value))
|
||||
{
|
||||
list.Add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
200
Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
Normal file
200
Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
Normal file
@@ -0,0 +1,200 @@
|
||||
#pragma warning disable RS0030 // Do not use banned APIs
|
||||
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Built-in SQL-based search provider that queries the library database directly.
|
||||
/// </summary>
|
||||
public class SqlSearchProvider : IInternalSearchProvider
|
||||
{
|
||||
private const int DefaultSearchLimit = 100;
|
||||
private const float ExactMatchScore = 100f;
|
||||
private const float PrefixMatchScore = 80f;
|
||||
private const float WordPrefixMatchScore = 75f;
|
||||
private const float ContainsMatchScore = 50f;
|
||||
|
||||
private static readonly Guid _placeholderId = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IItemTypeLookup _itemTypeLookup;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SqlSearchProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dbProvider">The database context factory.</param>
|
||||
/// <param name="itemTypeLookup">The item type lookup.</param>
|
||||
public SqlSearchProvider(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup)
|
||||
{
|
||||
_dbProvider = dbProvider;
|
||||
_itemTypeLookup = itemTypeLookup;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Name => "Database";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public MetadataPluginType Type => MetadataPluginType.SearchProvider;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int Priority => 100; // Low priority - runs as fallback
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool CanSearch(SearchProviderQuery query)
|
||||
{
|
||||
// SQL search can always handle any query
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<SearchResult>> SearchAsync(SearchProviderQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
|
||||
|
||||
var rawSearchTerm = query.SearchTerm.Trim().RemoveDiacritics();
|
||||
if (string.IsNullOrEmpty(rawSearchTerm))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var cleanSearchTerm = rawSearchTerm.GetCleanValue();
|
||||
if (string.IsNullOrEmpty(cleanSearchTerm))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var cleanPrefix = cleanSearchTerm + " ";
|
||||
// OriginalTitle is stored mixed-case and isn't pre-normalized like CleanName,
|
||||
// so match it via a case-insensitive LIKE rather than a per-row case conversion
|
||||
// that may not translate to SQL on every provider.
|
||||
var likeOriginal = $"%{rawSearchTerm}%";
|
||||
var limit = query.Limit ?? DefaultSearchLimit;
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
// Lightweight projection: select only what's needed to score and identify items.
|
||||
var dbQuery = dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Id != _placeholderId)
|
||||
.Where(e => !e.IsVirtualItem)
|
||||
.Where(e => e.CleanName!.Contains(cleanSearchTerm)
|
||||
|| (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal)));
|
||||
|
||||
dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes);
|
||||
dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes);
|
||||
dbQuery = ApplyParentFilter(dbQuery, query.ParentId);
|
||||
|
||||
// Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is
|
||||
// the pre-normalized (lowercase, diacritic-stripped) form, so we score against it
|
||||
// directly without any per-row case conversion. Items that match only via
|
||||
// OriginalTitle fall through to the Contains tier.
|
||||
// Tie-break by Id for deterministic ordering so the explicit OrderBy + Take
|
||||
// satisfies EF Core's row-limiting-with-OrderBy requirement.
|
||||
var scored = dbQuery.Select(e => new
|
||||
{
|
||||
e.Id,
|
||||
Score =
|
||||
(e.CleanName == cleanSearchTerm) ? ExactMatchScore
|
||||
: e.CleanName!.StartsWith(cleanSearchTerm) ? PrefixMatchScore
|
||||
: e.CleanName!.Contains(cleanPrefix) ? WordPrefixMatchScore
|
||||
: ContainsMatchScore
|
||||
});
|
||||
|
||||
var top = await scored
|
||||
.OrderByDescending(x => x.Score)
|
||||
.ThenBy(x => x.Id)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var results = new List<SearchResult>(top.Count);
|
||||
foreach (var row in top)
|
||||
{
|
||||
results.Add(new SearchResult(row.Id, row.Score));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
private IQueryable<BaseItemEntity> ApplyTypeFilter(
|
||||
IQueryable<BaseItemEntity> query,
|
||||
BaseItemKind[] includeItemTypes,
|
||||
BaseItemKind[] excludeItemTypes)
|
||||
{
|
||||
if (includeItemTypes.Length > 0)
|
||||
{
|
||||
var includeTypeNames = MapKindsToTypeNames(includeItemTypes);
|
||||
if (includeTypeNames.Count > 0)
|
||||
{
|
||||
query = query.Where(e => includeTypeNames.Contains(e.Type));
|
||||
}
|
||||
}
|
||||
else if (excludeItemTypes.Length > 0)
|
||||
{
|
||||
var excludeTypeNames = MapKindsToTypeNames(excludeItemTypes);
|
||||
if (excludeTypeNames.Count > 0)
|
||||
{
|
||||
query = query.Where(e => !excludeTypeNames.Contains(e.Type));
|
||||
}
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private static IQueryable<BaseItemEntity> ApplyMediaTypeFilter(
|
||||
IQueryable<BaseItemEntity> query,
|
||||
MediaType[] mediaTypes)
|
||||
{
|
||||
if (mediaTypes.Length == 0)
|
||||
{
|
||||
return query;
|
||||
}
|
||||
|
||||
var mediaTypeNames = mediaTypes.Select(m => m.ToString()).ToArray();
|
||||
return query.Where(e => e.MediaType != null && mediaTypeNames.Contains(e.MediaType));
|
||||
}
|
||||
|
||||
private static IQueryable<BaseItemEntity> ApplyParentFilter(
|
||||
IQueryable<BaseItemEntity> query,
|
||||
Guid? parentId)
|
||||
{
|
||||
if (!parentId.HasValue || parentId.Value.IsEmpty())
|
||||
{
|
||||
return query;
|
||||
}
|
||||
|
||||
var pid = parentId.Value;
|
||||
return query.Where(e => e.ParentId == pid || e.Parents!.Any(p => p.ParentItemId == pid));
|
||||
}
|
||||
|
||||
private List<string> MapKindsToTypeNames(BaseItemKind[] kinds)
|
||||
{
|
||||
var list = new List<string>(kinds.Length);
|
||||
foreach (var kind in kinds)
|
||||
{
|
||||
if (_itemTypeLookup.BaseItemKindNames.TryGetValue(kind, out var name) && name is not null)
|
||||
{
|
||||
list.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using MediaBrowser.Model.Search;
|
||||
|
||||
namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
public class SearchEngine : ISearchEngine
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
|
||||
public SearchEngine(ILibraryManager libraryManager, IUserManager userManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
public QueryResult<SearchHintInfo> GetSearchHints(SearchQuery query)
|
||||
{
|
||||
User? user = null;
|
||||
if (!query.UserId.IsEmpty())
|
||||
{
|
||||
user = _userManager.GetUserById(query.UserId);
|
||||
}
|
||||
|
||||
var results = GetSearchHints(query, user);
|
||||
var totalRecordCount = results.Count;
|
||||
|
||||
if (query.StartIndex.HasValue)
|
||||
{
|
||||
results = results.GetRange(query.StartIndex.Value, totalRecordCount - query.StartIndex.Value);
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0)
|
||||
{
|
||||
results = results.GetRange(0, Math.Min(query.Limit.Value, results.Count));
|
||||
}
|
||||
|
||||
return new QueryResult<SearchHintInfo>(
|
||||
query.StartIndex,
|
||||
totalRecordCount,
|
||||
results);
|
||||
}
|
||||
|
||||
private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value)
|
||||
{
|
||||
if (!list.Contains(value))
|
||||
{
|
||||
list.Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the search hints.
|
||||
/// </summary>
|
||||
/// <param name="query">The query.</param>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <returns>IEnumerable{SearchHintResult}.</returns>
|
||||
/// <exception cref="ArgumentException"><c>query.SearchTerm</c> is <c>null</c> or empty.</exception>
|
||||
private List<SearchHintInfo> GetSearchHints(SearchQuery query, User? user)
|
||||
{
|
||||
var searchTerm = query.SearchTerm;
|
||||
|
||||
ArgumentException.ThrowIfNullOrEmpty(searchTerm);
|
||||
|
||||
searchTerm = searchTerm.Trim().RemoveDiacritics();
|
||||
|
||||
var excludeItemTypes = query.ExcludeItemTypes.ToList();
|
||||
var includeItemTypes = query.IncludeItemTypes.ToList();
|
||||
|
||||
excludeItemTypes.Add(BaseItemKind.Year);
|
||||
excludeItemTypes.Add(BaseItemKind.Folder);
|
||||
|
||||
if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Genre)))
|
||||
{
|
||||
if (!query.IncludeMedia)
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.Genre);
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Genre);
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre);
|
||||
}
|
||||
|
||||
if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Person)))
|
||||
{
|
||||
if (!query.IncludeMedia)
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.Person);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Person);
|
||||
}
|
||||
|
||||
if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Studio)))
|
||||
{
|
||||
if (!query.IncludeMedia)
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.Studio);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Studio);
|
||||
}
|
||||
|
||||
if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.MusicArtist)))
|
||||
{
|
||||
if (!query.IncludeMedia)
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist);
|
||||
}
|
||||
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.CollectionFolder);
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Folder);
|
||||
var mediaTypes = query.MediaTypes.ToList();
|
||||
|
||||
if (includeItemTypes.Count > 0)
|
||||
{
|
||||
excludeItemTypes.Clear();
|
||||
mediaTypes.Clear();
|
||||
}
|
||||
|
||||
var searchQuery = new InternalItemsQuery(user)
|
||||
{
|
||||
SearchTerm = searchTerm,
|
||||
ExcludeItemTypes = excludeItemTypes.ToArray(),
|
||||
IncludeItemTypes = includeItemTypes.ToArray(),
|
||||
Limit = query.Limit,
|
||||
IncludeItemsByName = !query.ParentId.HasValue,
|
||||
ParentId = query.ParentId ?? Guid.Empty,
|
||||
OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) },
|
||||
Recursive = true,
|
||||
|
||||
IsKids = query.IsKids,
|
||||
IsMovie = query.IsMovie,
|
||||
IsNews = query.IsNews,
|
||||
IsSeries = query.IsSeries,
|
||||
IsSports = query.IsSports,
|
||||
MediaTypes = mediaTypes.ToArray(),
|
||||
|
||||
DtoOptions = new DtoOptions
|
||||
{
|
||||
Fields = new ItemFields[]
|
||||
{
|
||||
ItemFields.AirTime,
|
||||
ItemFields.DateCreated,
|
||||
ItemFields.ChannelInfo,
|
||||
ItemFields.ParentId
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
IReadOnlyList<BaseItem> mediaItems;
|
||||
|
||||
if (searchQuery.IncludeItemTypes.Length == 1 && searchQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist)
|
||||
{
|
||||
if (!searchQuery.ParentId.IsEmpty())
|
||||
{
|
||||
searchQuery.AncestorIds = [searchQuery.ParentId];
|
||||
searchQuery.ParentId = Guid.Empty;
|
||||
}
|
||||
|
||||
searchQuery.IncludeItemsByName = true;
|
||||
searchQuery.IncludeItemTypes = Array.Empty<BaseItemKind>();
|
||||
mediaItems = _libraryManager.GetAllArtists(searchQuery).Items.Select(i => i.Item).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
mediaItems = _libraryManager.GetItemList(searchQuery);
|
||||
}
|
||||
|
||||
return mediaItems.Select(i => new SearchHintInfo
|
||||
{
|
||||
Item = i
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
@@ -41,6 +42,7 @@ public class ItemsController : BaseJellyfinApiController
|
||||
private readonly ILogger<ItemsController> _logger;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly IUserDataManager _userDataRepository;
|
||||
private readonly ISearchManager _searchManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ItemsController"/> class.
|
||||
@@ -52,6 +54,7 @@ public class ItemsController : BaseJellyfinApiController
|
||||
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
|
||||
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
|
||||
/// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param>
|
||||
/// <param name="searchManager">Instance of the <see cref="ISearchManager"/> interface.</param>
|
||||
public ItemsController(
|
||||
IUserManager userManager,
|
||||
ILibraryManager libraryManager,
|
||||
@@ -59,7 +62,8 @@ public class ItemsController : BaseJellyfinApiController
|
||||
IDtoService dtoService,
|
||||
ILogger<ItemsController> logger,
|
||||
ISessionManager sessionManager,
|
||||
IUserDataManager userDataRepository)
|
||||
IUserDataManager userDataRepository,
|
||||
ISearchManager searchManager)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_libraryManager = libraryManager;
|
||||
@@ -68,6 +72,7 @@ public class ItemsController : BaseJellyfinApiController
|
||||
_logger = logger;
|
||||
_sessionManager = sessionManager;
|
||||
_userDataRepository = userDataRepository;
|
||||
_searchManager = searchManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -298,7 +303,7 @@ public class ItemsController : BaseJellyfinApiController
|
||||
if (collectionType == CollectionType.playlists)
|
||||
{
|
||||
recursive = true;
|
||||
includeItemTypes = new[] { BaseItemKind.Playlist };
|
||||
includeItemTypes = [BaseItemKind.Playlist];
|
||||
}
|
||||
else if (folder is ICollectionFolder)
|
||||
{
|
||||
@@ -328,6 +333,34 @@ public class ItemsController : BaseJellyfinApiController
|
||||
|
||||
if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder)
|
||||
{
|
||||
// Use search providers when searchTerm is provided. Providers return only IDs and scores;
|
||||
// items are loaded server-side via folder.GetItems below, which applies user-access filtering.
|
||||
Dictionary<Guid, float>? searchResultScores = null;
|
||||
Guid[] itemIds = ids;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
var searchProviderQuery = new SearchProviderQuery
|
||||
{
|
||||
SearchTerm = searchTerm,
|
||||
UserId = userId,
|
||||
IncludeItemTypes = includeItemTypes,
|
||||
ExcludeItemTypes = excludeItemTypes,
|
||||
MediaTypes = mediaTypes,
|
||||
Limit = limit.HasValue ? limit.Value * 3 : null,
|
||||
ParentId = parentId
|
||||
};
|
||||
|
||||
var searchResults = await _searchManager.GetSearchResultsAsync(searchProviderQuery, HttpContext.RequestAborted).ConfigureAwait(false);
|
||||
if (searchResults.Count > 0)
|
||||
{
|
||||
searchResultScores = searchResults.ToDictionary(r => r.ItemId, r => r.Score);
|
||||
itemIds = ids.Length > 0
|
||||
? ids.Concat(searchResultScores.Keys).Distinct().ToArray()
|
||||
: searchResultScores.Keys.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
var query = new InternalItemsQuery(user)
|
||||
{
|
||||
IsPlayed = isPlayed,
|
||||
@@ -337,8 +370,8 @@ public class ItemsController : BaseJellyfinApiController
|
||||
Recursive = recursive ?? false,
|
||||
OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
|
||||
IsFavorite = isFavorite,
|
||||
Limit = limit,
|
||||
StartIndex = startIndex,
|
||||
Limit = searchResultScores is not null ? null : limit,
|
||||
StartIndex = searchResultScores is not null ? null : startIndex,
|
||||
IsMissing = isMissing,
|
||||
IsUnaired = isUnaired,
|
||||
CollapseBoxSetItems = collapseBoxSetItems,
|
||||
@@ -385,7 +418,7 @@ public class ItemsController : BaseJellyfinApiController
|
||||
ImageTypes = imageTypes,
|
||||
VideoTypes = videoTypes,
|
||||
AdjacentTo = adjacentTo,
|
||||
ItemIds = ids,
|
||||
ItemIds = itemIds,
|
||||
MinCommunityRating = minCommunityRating,
|
||||
MinCriticRating = minCriticRating,
|
||||
ParentId = parentId ?? Guid.Empty,
|
||||
@@ -394,7 +427,7 @@ public class ItemsController : BaseJellyfinApiController
|
||||
EnableTotalRecordCount = enableTotalRecordCount,
|
||||
ExcludeItemIds = excludeItemIds,
|
||||
DtoOptions = dtoOptions,
|
||||
SearchTerm = searchTerm,
|
||||
SearchTerm = searchResultScores is null ? searchTerm : null,
|
||||
MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
|
||||
MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(),
|
||||
MinPremiereDate = minPremiereDate?.ToUniversalTime(),
|
||||
@@ -476,7 +509,7 @@ public class ItemsController : BaseJellyfinApiController
|
||||
{
|
||||
query.AlbumIds = albums.SelectMany(i =>
|
||||
{
|
||||
return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.MusicAlbum }, Name = i, Limit = 1 });
|
||||
return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Name = i, Limit = 1 });
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
@@ -502,12 +535,37 @@ public class ItemsController : BaseJellyfinApiController
|
||||
// Albums by artist
|
||||
if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum)
|
||||
{
|
||||
query.OrderBy = new[] { (ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending) };
|
||||
query.OrderBy = [(ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending)];
|
||||
}
|
||||
}
|
||||
|
||||
query.Parent = null;
|
||||
|
||||
// folder.GetItems applies user-access filtering via the InternalItemsQuery's User.
|
||||
result = folder.GetItems(query);
|
||||
if (searchResultScores is not null && searchResultScores.Count > 0)
|
||||
{
|
||||
var orderedItems = result.Items
|
||||
.OrderByDescending(item => searchResultScores.GetValueOrDefault(item.Id, 0f))
|
||||
.ThenBy(item => item.SortName)
|
||||
.ToArray();
|
||||
|
||||
var totalCount = orderedItems.Length;
|
||||
if (startIndex.HasValue && startIndex.Value > 0)
|
||||
{
|
||||
orderedItems = orderedItems.Skip(startIndex.Value).ToArray();
|
||||
}
|
||||
|
||||
if (limit.HasValue)
|
||||
{
|
||||
orderedItems = orderedItems.Take(limit.Value).ToArray();
|
||||
}
|
||||
|
||||
return new QueryResult<BaseItemDto>(
|
||||
startIndex,
|
||||
totalCount,
|
||||
_dtoService.GetBaseItemDtos(orderedItems, dtoOptions, user));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -861,7 +919,7 @@ public class ItemsController : BaseJellyfinApiController
|
||||
|
||||
var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
|
||||
{
|
||||
OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) },
|
||||
OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending)],
|
||||
IsResumable = true,
|
||||
StartIndex = startIndex,
|
||||
Limit = limit,
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.ComponentModel;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Data.Enums;
|
||||
@@ -29,7 +30,7 @@ namespace Jellyfin.Api.Controllers;
|
||||
[Authorize]
|
||||
public class SearchController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ISearchEngine _searchEngine;
|
||||
private readonly ISearchManager _searchManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IDtoService _dtoService;
|
||||
private readonly IImageProcessor _imageProcessor;
|
||||
@@ -37,17 +38,17 @@ public class SearchController : BaseJellyfinApiController
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SearchController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="searchEngine">Instance of <see cref="ISearchEngine"/> interface.</param>
|
||||
/// <param name="searchManager">Instance of <see cref="ISearchManager"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
|
||||
/// <param name="imageProcessor">Instance of <see cref="IImageProcessor"/> interface.</param>
|
||||
public SearchController(
|
||||
ISearchEngine searchEngine,
|
||||
ISearchManager searchManager,
|
||||
ILibraryManager libraryManager,
|
||||
IDtoService dtoService,
|
||||
IImageProcessor imageProcessor)
|
||||
{
|
||||
_searchEngine = searchEngine;
|
||||
_searchManager = searchManager;
|
||||
_libraryManager = libraryManager;
|
||||
_dtoService = dtoService;
|
||||
_imageProcessor = imageProcessor;
|
||||
@@ -79,7 +80,7 @@ public class SearchController : BaseJellyfinApiController
|
||||
[HttpGet]
|
||||
[Description("Gets search hints based on a search term")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<SearchHintResult> GetSearchHints(
|
||||
public async Task<ActionResult<SearchHintResult>> GetSearchHints(
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] Guid? userId,
|
||||
@@ -100,7 +101,7 @@ public class SearchController : BaseJellyfinApiController
|
||||
[FromQuery] bool includeArtists = true)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var result = _searchEngine.GetSearchHints(new SearchQuery
|
||||
var result = await _searchManager.GetSearchHintsAsync(new SearchQuery
|
||||
{
|
||||
Limit = limit,
|
||||
SearchTerm = searchTerm,
|
||||
@@ -121,7 +122,7 @@ public class SearchController : BaseJellyfinApiController
|
||||
IsNews = isNews,
|
||||
IsSeries = isSeries,
|
||||
IsSports = isSports
|
||||
});
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
return new SearchHintResult(result.Items.Select(GetSearchHintResult).ToArray(), result.TotalRecordCount);
|
||||
}
|
||||
|
||||
@@ -133,21 +133,15 @@ public sealed partial class BaseItemRepository
|
||||
IsSeries = filter.IsSeries
|
||||
});
|
||||
|
||||
// Keep this as an IQueryable sub-select. Materializing to a list would inline one
|
||||
// bound parameter per CleanValue and hit SQLite's variable cap on libraries with
|
||||
// high-cardinality value types (e.g. tens of thousands of artists).
|
||||
var matchingCleanValues = context.ItemValuesMap
|
||||
.Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
|
||||
.Join(
|
||||
innerQueryFilter,
|
||||
ivm => ivm.ItemId,
|
||||
g => g.Id,
|
||||
(ivm, g) => ivm.ItemValue.CleanValue)
|
||||
.Distinct();
|
||||
|
||||
// Use a correlated EXISTS rather than `IN (SELECT DISTINCT CleanValue ...)`. The
|
||||
// IN-form would force materialization of the full set of artist CleanValues across the
|
||||
// entire library before filtering.
|
||||
var innerQuery = PrepareItemQuery(context, filter)
|
||||
.Where(e => e.Type == returnType)
|
||||
.Where(e => matchingCleanValues.Contains(e.CleanName!));
|
||||
.Where(e => context.ItemValuesMap.Any(ivm =>
|
||||
itemValueTypes.Contains(ivm.ItemValue.Type)
|
||||
&& ivm.ItemValue.CleanValue == e.CleanName
|
||||
&& innerQueryFilter.Any(g => g.Id == ivm.ItemId)));
|
||||
|
||||
var outerQueryFilter = new InternalItemsQuery(filter.User)
|
||||
{
|
||||
@@ -174,9 +168,42 @@ public sealed partial class BaseItemRepository
|
||||
// (e.g. alternate versions) by picking the lowest Id per group.
|
||||
var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter);
|
||||
|
||||
var orderedMasterQuery = ApplyOrder(masterQuery, filter, context)
|
||||
.GroupBy(e => e.PresentationUniqueKey)
|
||||
.Select(g => g.Min(e => e.Id));
|
||||
IQueryable<Guid> orderedMasterQuery;
|
||||
if (!string.IsNullOrEmpty(filter.SearchTerm))
|
||||
{
|
||||
var cleanSearchTerm = filter.SearchTerm.GetCleanValue();
|
||||
var cleanSearchPrefix = cleanSearchTerm + " ";
|
||||
|
||||
orderedMasterQuery = masterQuery
|
||||
.Select(e => new
|
||||
{
|
||||
e.Id,
|
||||
e.PresentationUniqueKey,
|
||||
e.SortName,
|
||||
Score = (e.CleanName == cleanSearchTerm) ? 0
|
||||
: e.CleanName!.StartsWith(cleanSearchTerm) ? 1
|
||||
: e.CleanName!.Contains(cleanSearchPrefix) ? 2
|
||||
: 3
|
||||
})
|
||||
.GroupBy(x => x.PresentationUniqueKey)
|
||||
.Select(g => new
|
||||
{
|
||||
Id = g.Min(x => x.Id),
|
||||
Score = g.Min(x => x.Score),
|
||||
SortName = g.Min(x => x.SortName)
|
||||
})
|
||||
.OrderBy(x => x.Score)
|
||||
.ThenBy(x => x.SortName)
|
||||
.Select(x => x.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
orderedMasterQuery = masterQuery
|
||||
.GroupBy(e => e.PresentationUniqueKey)
|
||||
.Select(g => new { Id = g.Min(e => e.Id), SortName = g.Min(e => e.SortName) })
|
||||
.OrderBy(x => x.SortName)
|
||||
.Select(x => x.Id);
|
||||
}
|
||||
|
||||
var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
|
||||
if (filter.EnableTotalRecordCount)
|
||||
|
||||
@@ -932,24 +932,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.HasImdbId.HasValue)
|
||||
|
||||
@@ -351,6 +351,8 @@ namespace MediaBrowser.Controller.Entities
|
||||
|
||||
public Dictionary<string, string>? HasAnyProviderId { get; set; }
|
||||
|
||||
public Dictionary<string, string[]>? HasAnyProviderIds { get; set; }
|
||||
|
||||
public Guid[] AlbumArtistIds { get; set; }
|
||||
|
||||
public Guid[] BoxSetLibraryFolders { get; set; }
|
||||
|
||||
20
MediaBrowser.Controller/Library/IExternalSearchProvider.cs
Normal file
20
MediaBrowser.Controller/Library/IExternalSearchProvider.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
|
||||
namespace MediaBrowser.Controller.Library;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for external search providers that offer enhanced search capabilities.
|
||||
/// </summary>
|
||||
public interface IExternalSearchProvider : ISearchProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Searches for items matching the query.
|
||||
/// </summary>
|
||||
/// <param name="query">The search query.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Async enumerable of search results with relevance scores.</returns>
|
||||
new IAsyncEnumerable<SearchResult> SearchAsync(
|
||||
SearchProviderQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace MediaBrowser.Controller.Library;
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for internal search providers that typically query the local database directly.
|
||||
/// </summary>
|
||||
public interface IInternalSearchProvider : ISearchProvider
|
||||
{
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using MediaBrowser.Model.Querying;
|
||||
using MediaBrowser.Model.Search;
|
||||
|
||||
namespace MediaBrowser.Controller.Library
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface ILibrarySearchEngine.
|
||||
/// </summary>
|
||||
public interface ISearchEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the search hints.
|
||||
/// </summary>
|
||||
/// <param name="query">The query.</param>
|
||||
/// <returns>Task{IEnumerable{SearchHintInfo}}.</returns>
|
||||
QueryResult<SearchHintInfo> GetSearchHints(SearchQuery query);
|
||||
}
|
||||
}
|
||||
48
MediaBrowser.Controller/Library/ISearchManager.cs
Normal file
48
MediaBrowser.Controller/Library/ISearchManager.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using MediaBrowser.Model.Search;
|
||||
|
||||
namespace MediaBrowser.Controller.Library;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates search operations across registered search providers.
|
||||
/// </summary>
|
||||
public interface ISearchManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Searches for items and returns hints suitable for autocomplete/typeahead UI.
|
||||
/// Results are ordered by relevance score from search providers.
|
||||
/// </summary>
|
||||
/// <param name="query">The search query including filters and pagination.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Paginated search hints with item metadata for display.</returns>
|
||||
Task<QueryResult<SearchHintInfo>> GetSearchHintsAsync(
|
||||
SearchQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets ranked search results from registered providers. Returns only item IDs and
|
||||
/// relevance scores; callers are responsible for loading items and applying user-access filtering.
|
||||
/// </summary>
|
||||
/// <param name="query">The search provider query with type/media filters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Search results containing item IDs and relevance scores.</returns>
|
||||
Task<IReadOnlyList<SearchResult>> GetSearchResultsAsync(
|
||||
SearchProviderQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Registers search providers discovered through dependency injection.
|
||||
/// Called during application startup.
|
||||
/// </summary>
|
||||
/// <param name="providers">The search providers to register.</param>
|
||||
void AddParts(IEnumerable<ISearchProvider> providers);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered search providers ordered by priority.
|
||||
/// </summary>
|
||||
/// <returns>The list of search providers including the SQL fallback provider.</returns>
|
||||
IReadOnlyList<ISearchProvider> GetProviders();
|
||||
}
|
||||
44
MediaBrowser.Controller/Library/ISearchProvider.cs
Normal file
44
MediaBrowser.Controller/Library/ISearchProvider.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
|
||||
namespace MediaBrowser.Controller.Library;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for search providers.
|
||||
/// </summary>
|
||||
public interface ISearchProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the name of the provider.
|
||||
/// </summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type of the provider.
|
||||
/// </summary>
|
||||
MetadataPluginType Type { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the priority of the provider. Lower values execute first.
|
||||
/// </summary>
|
||||
int Priority { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Searches for items matching the query.
|
||||
/// </summary>
|
||||
/// <param name="query">The search query.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Ranked list of candidate item IDs with scores.</returns>
|
||||
Task<IReadOnlyList<SearchResult>> SearchAsync(
|
||||
SearchProviderQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether this provider can handle the given query.
|
||||
/// </summary>
|
||||
/// <param name="query">The search query to evaluate.</param>
|
||||
/// <returns>True if this provider can search for the query; otherwise, false.</returns>
|
||||
bool CanSearch(SearchProviderQuery query);
|
||||
}
|
||||
45
MediaBrowser.Controller/Library/SearchProviderQuery.cs
Normal file
45
MediaBrowser.Controller/Library/SearchProviderQuery.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using Jellyfin.Data.Enums;
|
||||
|
||||
namespace MediaBrowser.Controller.Library;
|
||||
|
||||
/// <summary>
|
||||
/// Query object for search providers.
|
||||
/// </summary>
|
||||
public class SearchProviderQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the search term.
|
||||
/// </summary>
|
||||
public required string SearchTerm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user ID for user-specific searches.
|
||||
/// </summary>
|
||||
public Guid? UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the item types to include in the search.
|
||||
/// </summary>
|
||||
public BaseItemKind[] IncludeItemTypes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the item types to exclude from the search.
|
||||
/// </summary>
|
||||
public BaseItemKind[] ExcludeItemTypes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the media types to include in the search.
|
||||
/// </summary>
|
||||
public MediaType[] MediaTypes { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of results to return.
|
||||
/// </summary>
|
||||
public int? Limit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parent ID to scope the search.
|
||||
/// </summary>
|
||||
public Guid? ParentId { get; init; }
|
||||
}
|
||||
60
MediaBrowser.Controller/Library/SearchResult.cs
Normal file
60
MediaBrowser.Controller/Library/SearchResult.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
|
||||
namespace MediaBrowser.Controller.Library;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an item matched by a search query with its relevance score.
|
||||
/// </summary>
|
||||
public readonly struct SearchResult : IEquatable<SearchResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SearchResult"/> struct.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item ID.</param>
|
||||
/// <param name="score">The relevance score.</param>
|
||||
public SearchResult(Guid itemId, float score)
|
||||
{
|
||||
ItemId = itemId;
|
||||
Score = score;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ID of the matching item.
|
||||
/// </summary>
|
||||
public Guid ItemId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the relevance score. Higher values indicate more relevant results.
|
||||
/// </summary>
|
||||
public float Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Compares two <see cref="SearchResult"/> instances for equality.
|
||||
/// </summary>
|
||||
/// <param name="left">The left operand.</param>
|
||||
/// <param name="right">The right operand.</param>
|
||||
/// <returns>True if the instances are equal; otherwise, false.</returns>
|
||||
public static bool operator ==(SearchResult left, SearchResult right)
|
||||
=> left.Equals(right);
|
||||
|
||||
/// <summary>
|
||||
/// Compares two <see cref="SearchResult"/> instances for inequality.
|
||||
/// </summary>
|
||||
/// <param name="left">The left operand.</param>
|
||||
/// <param name="right">The right operand.</param>
|
||||
/// <returns>True if the instances are not equal; otherwise, false.</returns>
|
||||
public static bool operator !=(SearchResult left, SearchResult right)
|
||||
=> !left.Equals(right);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool Equals(object? obj)
|
||||
=> obj is SearchResult other && Equals(other);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(SearchResult other)
|
||||
=> ItemId.Equals(other.ItemId) && Score.Equals(other.Score);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int GetHashCode()
|
||||
=> HashCode.Combine(ItemId, Score);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ namespace MediaBrowser.Model.Configuration
|
||||
MetadataSaver,
|
||||
SubtitleFetcher,
|
||||
LyricFetcher,
|
||||
MediaSegmentProvider
|
||||
MediaSegmentProvider,
|
||||
SearchProvider
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,92 @@ public static class JellyfinQueryHelperExtensions
|
||||
&& val.map.ItemId == item.Id) == EF.Constant(!invert);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters items that match any of the specified (provider name, value) pairs.
|
||||
/// </summary>
|
||||
/// <param name="baseQuery">The source query.</param>
|
||||
/// <param name="providerIds">Dictionary mapping provider names to arrays of values to match.</param>
|
||||
/// <returns>A filtered query.</returns>
|
||||
public static IQueryable<BaseItemEntity> WhereHasAnyProviderIds(
|
||||
this IQueryable<BaseItemEntity> baseQuery,
|
||||
IReadOnlyDictionary<string, string[]> providerIds)
|
||||
{
|
||||
var providerKeys = providerIds
|
||||
.SelectMany(kvp => kvp.Value.Select(v => $"{kvp.Key}:{v}"))
|
||||
.ToList();
|
||||
|
||||
if (providerKeys.Count == 0)
|
||||
{
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
return baseQuery.Where(e => e.Provider!.Any(p => providerKeys.Contains(p.ProviderId + ":" + p.ProviderValue)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters items that have any of the specified providers. Empty/null values match any value for that provider.
|
||||
/// </summary>
|
||||
/// <param name="baseQuery">The source query.</param>
|
||||
/// <param name="providerIds">Dictionary mapping provider names to optional values.</param>
|
||||
/// <returns>A filtered query.</returns>
|
||||
public static IQueryable<BaseItemEntity> WhereHasAnyProviderId(
|
||||
this IQueryable<BaseItemEntity> baseQuery,
|
||||
IReadOnlyDictionary<string, string> providerIds)
|
||||
{
|
||||
var existenceOnly = providerIds
|
||||
.Where(e => string.IsNullOrEmpty(e.Value))
|
||||
.Select(e => e.Key)
|
||||
.ToList();
|
||||
|
||||
var specificValues = providerIds
|
||||
.Where(e => !string.IsNullOrEmpty(e.Value))
|
||||
.Select(e => $"{e.Key}:{e.Value}")
|
||||
.ToList();
|
||||
|
||||
if (existenceOnly.Count == 0 && specificValues.Count == 0)
|
||||
{
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
if (existenceOnly.Count == 0)
|
||||
{
|
||||
return baseQuery.Where(e => e.Provider!.Any(p =>
|
||||
specificValues.Contains(p.ProviderId + ":" + p.ProviderValue)));
|
||||
}
|
||||
|
||||
if (specificValues.Count == 0)
|
||||
{
|
||||
return baseQuery.Where(e => e.Provider!.Any(p => existenceOnly.Contains(p.ProviderId)));
|
||||
}
|
||||
|
||||
// Single EXISTS over Provider with both predicates OR'd, instead of two separate subqueries.
|
||||
return baseQuery.Where(e => e.Provider!.Any(p =>
|
||||
existenceOnly.Contains(p.ProviderId) ||
|
||||
specificValues.Contains(p.ProviderId + ":" + p.ProviderValue)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Excludes items that match any of the specified (provider name, value) pairs.
|
||||
/// </summary>
|
||||
/// <param name="baseQuery">The source query.</param>
|
||||
/// <param name="providerIds">Dictionary mapping provider names to values to exclude.</param>
|
||||
/// <returns>A filtered query.</returns>
|
||||
public static IQueryable<BaseItemEntity> WhereExcludeProviderIds(
|
||||
this IQueryable<BaseItemEntity> baseQuery,
|
||||
IReadOnlyDictionary<string, string> providerIds)
|
||||
{
|
||||
var excludeKeys = providerIds
|
||||
.Select(e => $"{e.Key}:{e.Value}")
|
||||
.ToList();
|
||||
|
||||
if (excludeKeys.Count == 0)
|
||||
{
|
||||
return baseQuery;
|
||||
}
|
||||
|
||||
return baseQuery.Where(e => e.Provider!.All(p => !excludeKeys.Contains(p.ProviderId + ":" + p.ProviderValue)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an optimised query expression checking one property against a list of values while maintaining an optimal query.
|
||||
/// </summary>
|
||||
@@ -138,13 +224,13 @@ public static class JellyfinQueryHelperExtensions
|
||||
|
||||
var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key));
|
||||
|
||||
if (oneOf.Count < 4) // arbitrary value choosen.
|
||||
{
|
||||
// if we have 3 or fewer values to check against its faster to do a IN(const,const,const) lookup
|
||||
return Expression.Lambda<Func<TEntity, bool>>(Expression.Call(null, containsMethodInfo, Expression.Constant(oneOf), property.Body), parameter);
|
||||
}
|
||||
|
||||
return Expression.Lambda<Func<TEntity, bool>>(Expression.Call(null, containsMethodInfo, Expression.Call(null, _efParameterInstruction.MakeGenericMethod(oneOf.GetType()), Expression.Constant(oneOf)), property.Body), parameter);
|
||||
return Expression.Lambda<Func<TEntity, bool>>(
|
||||
Expression.Call(
|
||||
null,
|
||||
containsMethodInfo,
|
||||
Expression.Call(null, _efParameterInstruction.MakeGenericMethod(oneOf.GetType()), Expression.Constant(oneOf)),
|
||||
property.Body),
|
||||
parameter);
|
||||
}
|
||||
|
||||
internal static class ParameterReplacer
|
||||
|
||||
Reference in New Issue
Block a user