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