aboutsummaryrefslogtreecommitdiff
path: root/Emby.Server.Implementations
diff options
context:
space:
mode:
authorBond-009 <bond.009@outlook.com>2026-06-07 22:56:51 +0200
committerGitHub <noreply@github.com>2026-06-07 22:56:51 +0200
commit4459147788c0a11d3039723644708f8c8f7cdf2d (patch)
tree2d76561f3f60d62fb4ff1ebff3ee04ac97d35df6 /Emby.Server.Implementations
parent003f01a99aa7765c5380ff8cff0955addb72d083 (diff)
parentd8d386e88a8bd27ef8e40497e302db184cc02b08 (diff)
Merge pull request #16121 from Shadowghost/search-rebased
Implement search providers
Diffstat (limited to 'Emby.Server.Implementations')
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs5
-rw-r--r--Emby.Server.Implementations/Library/Search/SearchManager.cs458
-rw-r--r--Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs230
-rw-r--r--Emby.Server.Implementations/Library/SearchEngine.cs200
4 files changed, 692 insertions, 201 deletions
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs
index 7cbff0c67e..14380c33bf 100644
--- a/Emby.Server.Implementations/ApplicationHost.cs
+++ b/Emby.Server.Implementations/ApplicationHost.cs
@@ -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>
diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs
new file mode 100644
index 0000000000..a5be3f07bd
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs
@@ -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);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
new file mode 100644
index 0000000000..bc766f1c8c
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
@@ -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;
+ }
+}
diff --git a/Emby.Server.Implementations/Library/SearchEngine.cs b/Emby.Server.Implementations/Library/SearchEngine.cs
deleted file mode 100644
index c682118597..0000000000
--- a/Emby.Server.Implementations/Library/SearchEngine.cs
+++ /dev/null
@@ -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();
- }
- }
-}