Merge pull request #16121 from Shadowghost/search-rebased
Some checks are pending
CodeQL / Analyze (csharp) (push) Waiting to run
Format / format-check (push) Waiting to run
Tests / run-tests (macos-latest) (push) Waiting to run
Tests / run-tests (ubuntu-latest) (push) Waiting to run
Tests / run-tests (windows-latest) (push) Waiting to run
OpenAPI Publish / OpenAPI - Publish Artifact (push) Waiting to run
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Blocked by required conditions
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Blocked by required conditions
Project Automation / Project board (push) Waiting to run
Merge Conflict Labeler / main (push) Waiting to run

Implement search providers
This commit is contained in:
Bond-009
2026-06-07 22:56:51 +02:00
committed by GitHub
16 changed files with 1089 additions and 251 deletions

View File

@@ -26,6 +26,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.Library.SimilarItems;
using Emby.Server.Implementations.Localization;
using Emby.Server.Implementations.Playlists;
@@ -551,7 +552,8 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<ISimilarItemsManager, SimilarItemsManager>();
serviceCollection.AddSingleton<ISearchEngine, SearchEngine>();
serviceCollection.AddSingleton<ISearchManager, SearchManager>();
serviceCollection.AddSingleton<ISearchProvider, SqlSearchProvider>();
serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>();
@@ -710,6 +712,7 @@ namespace Emby.Server.Implementations
Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>());
Resolve<ISimilarItemsManager>().AddParts(GetExports<ISimilarItemsProvider>());
Resolve<ISearchManager>().AddParts(GetExports<ISearchProvider>());
}
/// <summary>

View File

@@ -0,0 +1,458 @@
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 externalTask = CollectFromProvidersAsync(_externalProviders, query, searchTerm, cancellationToken);
var internalTask = _internalProviders.Length > 0
? CollectFromProvidersAsync(_internalProviders, query, searchTerm, cancellationToken)
: Task.FromResult<IReadOnlyList<SearchResult>>([]);
await Task.WhenAll(externalTask, internalTask).ConfigureAwait(false);
var externalResults = await externalTask.ConfigureAwait(false);
var fromExternal = externalResults.Count > 0;
IReadOnlyList<SearchResult> results;
if (fromExternal)
{
results = externalResults;
}
else
{
results = await internalTask.ConfigureAwait(false);
if (_internalProviders.Length > 0)
{
_logger.LogDebug("No results from external providers, using internal provider results");
}
}
// Internal providers apply user-access filtering inline in their queries. External
// providers don't know about user permissions, so they may return IDs from hidden
// libraries or items the user is otherwise blocked from. Run the post-filter only
// when results came from externals to close that gap. 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.
if (fromExternal && 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);
Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)];
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var baseQuery = dbContext.BaseItems
.AsNoTracking()
.WhereOneOrMany(candidateIds, e => e.Id);
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false);
if (allowedCount == candidates.Count)
{
return candidates;
}
var allowedIds = await baseQuery
.Select(e => e.Id)
.ToHashSetAsync(cancellationToken)
.ConfigureAwait(false);
var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList();
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() ? null : _userManager.GetUserById(query.UserId);
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 requestedLimit = providerQuery.Limit ?? 100;
var applicable = providers.Where(p => p.CanSearch(providerQuery)).ToArray();
if (applicable.Length == 0)
{
return [];
}
var perProvider = await Task.WhenAll(
applicable.Select(p => CollectFromProviderAsync(p, providerQuery, searchTerm, requestedLimit, cancellationToken)))
.ConfigureAwait(false);
var bestScores = new Dictionary<Guid, float>();
foreach (var providerResults in perProvider)
{
foreach (var result in providerResults)
{
UpdateBestScore(bestScores, result);
}
}
return bestScores
.Select(kvp => new SearchResult(kvp.Key, kvp.Value))
.OrderByDescending(r => r.Score)
.Take(requestedLimit)
.ToList();
}
private async Task<IReadOnlyList<SearchResult>> CollectFromProviderAsync(
ISearchProvider provider,
SearchProviderQuery providerQuery,
string searchTerm,
int requestedLimit,
CancellationToken cancellationToken)
{
try
{
var results = provider is IExternalSearchProvider externalProvider
? await CollectFromExternalProviderAsync(externalProvider, providerQuery, requestedLimit, cancellationToken).ConfigureAwait(false)
: await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'",
provider.Name,
results.Count,
searchTerm);
return results;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTerm);
return [];
}
}
private static async Task<IReadOnlyList<SearchResult>> CollectFromExternalProviderAsync(
IExternalSearchProvider provider,
SearchProviderQuery providerQuery,
int requestedLimit,
CancellationToken cancellationToken)
{
var results = new List<SearchResult>();
await foreach (var result in provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false))
{
results.Add(result);
if (results.Count >= requestedLimit)
{
break;
}
}
return results;
}
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.IncludeMedia)
{
return includeItemTypes;
}
if (query.IncludeGenres && IsEmptyOrContains(includeItemTypes, BaseItemKind.Genre))
{
AddIfMissing(includeItemTypes, BaseItemKind.Genre);
AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre);
}
if (query.IncludePeople && IsEmptyOrContains(includeItemTypes, BaseItemKind.Person))
{
AddIfMissing(includeItemTypes, BaseItemKind.Person);
}
if (query.IncludeStudios && IsEmptyOrContains(includeItemTypes, BaseItemKind.Studio))
{
AddIfMissing(includeItemTypes, BaseItemKind.Studio);
}
if (query.IncludeArtists && IsEmptyOrContains(includeItemTypes, BaseItemKind.MusicArtist))
{
AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist);
}
return includeItemTypes;
}
private static bool IsEmptyOrContains(List<BaseItemKind> list, BaseItemKind value)
=> list.Count == 0 || list.Contains(value);
private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value)
{
if (!list.Contains(value))
{
list.Add(value);
}
}
}

View File

@@ -0,0 +1,230 @@
#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.Entities;
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;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
private readonly IItemQueryHelpers _queryHelpers;
/// <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>
/// <param name="libraryManager">The library manager.</param>
/// <param name="userManager">The user manager.</param>
/// <param name="queryHelpers">The shared item query helpers.</param>
public SqlSearchProvider(
IDbContextFactory<JellyfinDbContext> dbProvider,
IItemTypeLookup itemTypeLookup,
ILibraryManager libraryManager,
IUserManager userManager,
IItemQueryHelpers queryHelpers)
{
_dbProvider = dbProvider;
_itemTypeLookup = itemTypeLookup;
_libraryManager = libraryManager;
_userManager = userManager;
_queryHelpers = queryHelpers;
}
/// <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);
dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query.UserId);
// 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
});
return await scored
.OrderByDescending(x => x.Score)
.ThenBy(x => x.Id)
.Take(limit)
.Select(x => new SearchResult(x.Id, x.Score))
.ToArrayAsync(cancellationToken)
.ConfigureAwait(false);
}
}
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 IQueryable<BaseItemEntity> ApplyUserAccessFilter(
JellyfinDbContext dbContext,
IQueryable<BaseItemEntity> query,
Guid? userId)
{
if (!userId.HasValue || userId.Value.IsEmpty())
{
return query;
}
var user = _userManager.GetUserById(userId.Value);
if (user is null)
{
return query;
}
var accessFilter = new InternalItemsQuery(user);
_libraryManager.ConfigureUserAccess(accessFilter, user);
return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter);
}
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;
}
}

View File

@@ -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();
}
}
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
@@ -42,6 +43,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.
@@ -53,6 +55,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,
@@ -60,7 +63,8 @@ public class ItemsController : BaseJellyfinApiController
IDtoService dtoService,
ILogger<ItemsController> logger,
ISessionManager sessionManager,
IUserDataManager userDataRepository)
IUserDataManager userDataRepository,
ISearchManager searchManager)
{
_userManager = userManager;
_libraryManager = libraryManager;
@@ -69,6 +73,7 @@ public class ItemsController : BaseJellyfinApiController
_logger = logger;
_sessionManager = sessionManager;
_userDataRepository = userDataRepository;
_searchManager = searchManager;
}
/// <summary>
@@ -314,7 +319,7 @@ public class ItemsController : BaseJellyfinApiController
if (collectionType == CollectionType.playlists)
{
recursive = true;
includeItemTypes = new[] { BaseItemKind.Playlist };
includeItemTypes = [BaseItemKind.Playlist];
}
else if (folder is ICollectionFolder)
{
@@ -348,6 +353,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,
@@ -357,8 +390,8 @@ public class ItemsController : BaseJellyfinApiController
Recursive = recursive ?? false,
OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
IsFavorite = isFavorite,
Limit = limit,
StartIndex = startIndex,
Limit = searchResultScores is null ? limit : null,
StartIndex = searchResultScores is null ? startIndex : null,
IsMissing = isMissing,
IsUnaired = isUnaired,
CollapseBoxSetItems = collapseBoxSetItems,
@@ -405,7 +438,7 @@ public class ItemsController : BaseJellyfinApiController
ImageTypes = imageTypes,
VideoTypes = videoTypes,
AdjacentTo = adjacentTo,
ItemIds = ids,
ItemIds = itemIds,
MinCommunityRating = minCommunityRating,
MinCriticRating = minCriticRating,
ParentId = parentId ?? Guid.Empty,
@@ -414,7 +447,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(),
@@ -526,7 +559,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();
}
@@ -552,12 +585,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
{
@@ -913,7 +971,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,

View File

@@ -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);
}

View File

@@ -953,24 +953,17 @@ public sealed partial class BaseItemRepository
if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0)
{
var exclude = filter.ExcludeProviderIds.Select(e => $"{e.Key}:{e.Value}").ToArray();
baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.All(f => !exclude.Contains(f)));
baseQuery = baseQuery.WhereExcludeProviderIds(filter.ExcludeProviderIds);
}
if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0)
{
// Allow setting a null or empty value to get all items that have the specified provider set.
var includeAny = filter.HasAnyProviderId.Where(e => string.IsNullOrEmpty(e.Value)).Select(e => e.Key).ToArray();
if (includeAny.Length > 0)
{
baseQuery = baseQuery.Where(e => e.Provider!.Any(f => includeAny.Contains(f.ProviderId)));
}
baseQuery = baseQuery.WhereHasAnyProviderId(filter.HasAnyProviderId);
}
var includeSelected = filter.HasAnyProviderId.Where(e => !string.IsNullOrEmpty(e.Value)).Select(e => $"{e.Key}:{e.Value}").ToArray();
if (includeSelected.Length > 0)
{
baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeSelected.Contains(f)));
}
if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0)
{
baseQuery = baseQuery.WhereHasAnyProviderIds(filter.HasAnyProviderIds);
}
if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0)

View 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);
}

View File

@@ -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
{
}

View File

@@ -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);
}
}

View 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();
}

View 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);
}

View 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; }
}

View 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);
}

View File

@@ -17,6 +17,7 @@ namespace MediaBrowser.Model.Configuration
LyricFetcher,
MediaSegmentProvider,
LocalSimilarityProvider,
SimilarityProvider
SimilarityProvider,
SearchProvider
}
}

View File

@@ -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,9 +224,10 @@ public static class JellyfinQueryHelperExtensions
var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key));
if (oneOf.Count < 4) // arbitrary value choosen.
// Threshold picked from microbenchmarks on SQLite: inline IN(const,...) beats a
// parameterized array lookup by ~5-10% up to ~32 elements.
if (oneOf.Count <= 32)
{
// 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);
}