diff options
| author | Bond-009 <bond.009@outlook.com> | 2026-06-07 22:56:51 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-07 22:56:51 +0200 |
| commit | 4459147788c0a11d3039723644708f8c8f7cdf2d (patch) | |
| tree | 2d76561f3f60d62fb4ff1ebff3ee04ac97d35df6 | |
| parent | 003f01a99aa7765c5380ff8cff0955addb72d083 (diff) | |
| parent | d8d386e88a8bd27ef8e40497e302db184cc02b08 (diff) | |
Merge pull request #16121 from Shadowghost/search-rebased
Implement search providers
16 files changed, 1089 insertions, 251 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(); - } - } -} diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 5705284cfb..9115227707 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -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, diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs index ecf2335ba0..b03cb88e75 100644 --- a/Jellyfin.Api/Controllers/SearchController.cs +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -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); } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index f33a65a703..d905775aef 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -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) diff --git a/MediaBrowser.Controller/Library/IExternalSearchProvider.cs b/MediaBrowser.Controller/Library/IExternalSearchProvider.cs new file mode 100644 index 0000000000..bded8ba3a3 --- /dev/null +++ b/MediaBrowser.Controller/Library/IExternalSearchProvider.cs @@ -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); +} diff --git a/MediaBrowser.Controller/Library/IInternalSearchProvider.cs b/MediaBrowser.Controller/Library/IInternalSearchProvider.cs new file mode 100644 index 0000000000..f87931395d --- /dev/null +++ b/MediaBrowser.Controller/Library/IInternalSearchProvider.cs @@ -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 +{ +} diff --git a/MediaBrowser.Controller/Library/ISearchEngine.cs b/MediaBrowser.Controller/Library/ISearchEngine.cs deleted file mode 100644 index 31dcbba5bd..0000000000 --- a/MediaBrowser.Controller/Library/ISearchEngine.cs +++ /dev/null @@ -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); - } -} diff --git a/MediaBrowser.Controller/Library/ISearchManager.cs b/MediaBrowser.Controller/Library/ISearchManager.cs new file mode 100644 index 0000000000..4f763829a7 --- /dev/null +++ b/MediaBrowser.Controller/Library/ISearchManager.cs @@ -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(); +} diff --git a/MediaBrowser.Controller/Library/ISearchProvider.cs b/MediaBrowser.Controller/Library/ISearchProvider.cs new file mode 100644 index 0000000000..3b300ed38b --- /dev/null +++ b/MediaBrowser.Controller/Library/ISearchProvider.cs @@ -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); +} diff --git a/MediaBrowser.Controller/Library/SearchProviderQuery.cs b/MediaBrowser.Controller/Library/SearchProviderQuery.cs new file mode 100644 index 0000000000..845588c872 --- /dev/null +++ b/MediaBrowser.Controller/Library/SearchProviderQuery.cs @@ -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; } +} diff --git a/MediaBrowser.Controller/Library/SearchResult.cs b/MediaBrowser.Controller/Library/SearchResult.cs new file mode 100644 index 0000000000..e6f145e979 --- /dev/null +++ b/MediaBrowser.Controller/Library/SearchResult.cs @@ -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); +} diff --git a/MediaBrowser.Model/Configuration/MetadataPluginType.cs b/MediaBrowser.Model/Configuration/MetadataPluginType.cs index 476060ceef..dd9a599a29 100644 --- a/MediaBrowser.Model/Configuration/MetadataPluginType.cs +++ b/MediaBrowser.Model/Configuration/MetadataPluginType.cs @@ -17,6 +17,7 @@ namespace MediaBrowser.Model.Configuration LyricFetcher, MediaSegmentProvider, LocalSimilarityProvider, - SimilarityProvider + SimilarityProvider, + SearchProvider } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs index f386e882e2..1af7460540 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs @@ -112,6 +112,92 @@ public static class JellyfinQueryHelperExtensions } /// <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> /// <typeparam name="TEntity">The entity.</typeparam> @@ -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); } |
