#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; /// /// 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; private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; private readonly IItemQueryHelpers _queryHelpers; /// /// Initializes a new instance of the class. /// /// The database context factory. /// The item type lookup. /// The library manager. /// The user manager. /// The shared item query helpers. public SqlSearchProvider( IDbContextFactory dbProvider, IItemTypeLookup itemTypeLookup, ILibraryManager libraryManager, IUserManager userManager, IItemQueryHelpers queryHelpers) { _dbProvider = dbProvider; _itemTypeLookup = itemTypeLookup; _libraryManager = libraryManager; _userManager = userManager; _queryHelpers = queryHelpers; } /// 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); 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 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 IQueryable ApplyUserAccessFilter( JellyfinDbContext dbContext, IQueryable 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 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; } }