< Summary - Jellyfin

Information
Class: Emby.Server.Implementations.Library.Search.SqlSearchProvider
Assembly: Emby.Server.Implementations
File(s): /srv/git/jellyfin/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
Line coverage
74%
Covered lines: 69
Uncovered lines: 24
Coverable lines: 93
Total lines: 260
Line coverage: 74.1%
Branch coverage
35%
Covered branches: 12
Total branches: 34
Branch coverage: 35.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 6/8/2026 - 12:16:15 AM Line coverage: 9.8% (8/81) Branch coverage: 0% (0/30) Total lines: 23010/29/2026 - 12:17:11 AM Line coverage: 74.1% (69/93) Branch coverage: 35.2% (12/34) Total lines: 260 6/8/2026 - 12:16:15 AM Line coverage: 9.8% (8/81) Branch coverage: 0% (0/30) Total lines: 23010/29/2026 - 12:17:11 AM Line coverage: 74.1% (69/93) Branch coverage: 35.2% (12/34) Total lines: 260

Coverage delta

Coverage delta 65 -65

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
get_Name()100%11100%
get_Type()100%210%
get_Priority()100%11100%
CanSearch(...)100%210%
SearchAsync()50%8889.79%
ApplyTypeFilter(...)25%27833.33%
ApplyMediaTypeFilter(...)50%3250%
ApplyParentFilter(...)50%6450%
ExcludeVersionsOfMatchedPrimaries(...)100%11100%
ApplyUserAccessFilter(...)50%7675%
MapKindsToTypeNames(...)0%4260%

File(s)

/srv/git/jellyfin/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs

#LineLine coverage
 1#pragma warning disable RS0030 // Do not use banned APIs
 2#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string compari
 3
 4using System;
 5using System.Collections.Generic;
 6using System.Linq;
 7using System.Threading;
 8using System.Threading.Tasks;
 9using Jellyfin.Data.Enums;
 10using Jellyfin.Database.Implementations;
 11using Jellyfin.Database.Implementations.Entities;
 12using Jellyfin.Extensions;
 13using MediaBrowser.Controller.Configuration;
 14using MediaBrowser.Controller.Entities;
 15using MediaBrowser.Controller.Library;
 16using MediaBrowser.Controller.Persistence;
 17using MediaBrowser.Model.Configuration;
 18using Microsoft.EntityFrameworkCore;
 19
 20namespace Emby.Server.Implementations.Library.Search;
 21
 22/// <summary>
 23/// Built-in SQL-based search provider that queries the library database directly.
 24/// </summary>
 25public class SqlSearchProvider : IInternalSearchProvider
 26{
 27    private const int DefaultSearchLimit = 100;
 28    private const float ExactMatchScore = 100f;
 29    private const float PrefixMatchScore = 80f;
 30    private const float WordPrefixMatchScore = 75f;
 31    private const float ContainsMatchScore = 50f;
 32
 133    private static readonly Guid _placeholderId = Guid.Parse("00000000-0000-0000-0000-000000000001");
 34
 35    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 36    private readonly IItemTypeLookup _itemTypeLookup;
 37    private readonly ILibraryManager _libraryManager;
 38    private readonly IUserManager _userManager;
 39    private readonly IItemQueryHelpers _queryHelpers;
 40    private readonly IServerConfigurationManager _configurationManager;
 41
 42    /// <summary>
 43    /// Initializes a new instance of the <see cref="SqlSearchProvider"/> class.
 44    /// </summary>
 45    /// <param name="dbProvider">The database context factory.</param>
 46    /// <param name="itemTypeLookup">The item type lookup.</param>
 47    /// <param name="libraryManager">The library manager.</param>
 48    /// <param name="userManager">The user manager.</param>
 49    /// <param name="queryHelpers">The shared item query helpers.</param>
 50    /// <param name="configurationManager">The configuration manager.</param>
 51    public SqlSearchProvider(
 52        IDbContextFactory<JellyfinDbContext> dbProvider,
 53        IItemTypeLookup itemTypeLookup,
 54        ILibraryManager libraryManager,
 55        IUserManager userManager,
 56        IItemQueryHelpers queryHelpers,
 57        IServerConfigurationManager configurationManager)
 58    {
 2959        _dbProvider = dbProvider;
 2960        _itemTypeLookup = itemTypeLookup;
 2961        _libraryManager = libraryManager;
 2962        _userManager = userManager;
 2963        _queryHelpers = queryHelpers;
 2964        _configurationManager = configurationManager;
 2965    }
 66
 67    /// <inheritdoc/>
 2768    public string Name => "Database";
 69
 70    /// <inheritdoc/>
 071    public MetadataPluginType Type => MetadataPluginType.SearchProvider;
 72
 73    /// <inheritdoc/>
 2774    public int Priority => 100; // Low priority - runs as fallback
 75
 76    /// <inheritdoc/>
 77    public bool CanSearch(SearchProviderQuery query)
 78    {
 79        // SQL search can always handle any query
 080        return true;
 81    }
 82
 83    /// <inheritdoc/>
 84    public async Task<IReadOnlyList<SearchResult>> SearchAsync(SearchProviderQuery query, CancellationToken cancellation
 85    {
 286        ArgumentNullException.ThrowIfNull(query);
 287        ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
 88
 289        var rawSearchTerm = query.SearchTerm.Trim().RemoveDiacritics();
 290        if (string.IsNullOrEmpty(rawSearchTerm))
 91        {
 092            return [];
 93        }
 94
 295        var cleanSearchTerm = rawSearchTerm.GetCleanValue();
 296        if (string.IsNullOrEmpty(cleanSearchTerm))
 97        {
 098            return [];
 99        }
 100
 2101        var cleanPrefix = cleanSearchTerm + " ";
 102        // OriginalTitle is stored mixed-case and isn't pre-normalized like CleanName,
 103        // so match it via a case-insensitive LIKE rather than a per-row case conversion
 104        // that may not translate to SQL on every provider.
 2105        var likeOriginal = $"%{rawSearchTerm}%";
 106
 107        // Great benefit for users with metadata in non-latin script
 108        // since SortName is among other things stored transliterated.
 2109        var sortNameShape = BaseItem.GetSortName(rawSearchTerm, true, _configurationManager.Configuration);
 2110        var likeSortName = string.IsNullOrWhiteSpace(sortNameShape) ? null : $"%{sortNameShape}%";
 111
 2112        var limit = query.Limit ?? DefaultSearchLimit;
 113
 2114        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 2115        await using (dbContext.ConfigureAwait(false))
 116        {
 117            // Lightweight projection: select only what's needed to score and identify items.
 2118            var dbQuery = dbContext.BaseItems
 2119                .AsNoTracking()
 2120                .Where(e => e.Id != _placeholderId)
 2121                .Where(e => !e.IsVirtualItem);
 122
 2123            if (likeSortName is null)
 124            {
 0125                dbQuery = dbQuery
 0126                    .Where(e => e.CleanName!.Contains(cleanSearchTerm)
 0127                        || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal)));
 128            }
 129            else
 130            {
 2131                dbQuery = dbQuery
 2132                    .Where(e => e.CleanName!.Contains(cleanSearchTerm)
 2133                        || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal))
 2134                        || (e.SortName != null && EF.Functions.Like(e.SortName, likeSortName)));
 135            }
 136
 2137            dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes);
 2138            dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes);
 2139            dbQuery = ApplyParentFilter(dbQuery, query.ParentId);
 2140            dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query);
 2141            dbQuery = ExcludeVersionsOfMatchedPrimaries(dbQuery);
 142
 143            // Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is
 144            // the pre-normalized (lowercase, diacritic-stripped) form, so we score against it
 145            // directly without any per-row case conversion. Items that match only via
 146            // OriginalTitle fall through to the Contains tier.
 147            // Tie-break by Id for deterministic ordering so the explicit OrderBy + Take
 148            // satisfies EF Core's row-limiting-with-OrderBy requirement.
 2149            var scored = dbQuery.Select(e => new
 2150            {
 2151                e.Id,
 2152                Score =
 2153                    (e.CleanName == cleanSearchTerm) ? ExactMatchScore
 2154                    : e.CleanName!.StartsWith(cleanSearchTerm) ? PrefixMatchScore
 2155                    : e.CleanName!.Contains(cleanPrefix) ? WordPrefixMatchScore
 2156                    : ContainsMatchScore
 2157            });
 158
 2159            return await scored
 2160                .OrderByDescending(x => x.Score)
 2161                .ThenBy(x => x.Id)
 2162                .Take(limit)
 2163                .Select(x => new SearchResult(x.Id, x.Score))
 2164                .ToArrayAsync(cancellationToken)
 2165                .ConfigureAwait(false);
 166        }
 2167    }
 168
 169    private IQueryable<BaseItemEntity> ApplyTypeFilter(
 170        IQueryable<BaseItemEntity> query,
 171        BaseItemKind[] includeItemTypes,
 172        BaseItemKind[] excludeItemTypes)
 173    {
 2174        if (includeItemTypes.Length > 0)
 175        {
 0176            var includeTypeNames = MapKindsToTypeNames(includeItemTypes);
 0177            if (includeTypeNames.Count > 0)
 178            {
 0179                query = query.Where(e => includeTypeNames.Contains(e.Type));
 180            }
 181        }
 2182        else if (excludeItemTypes.Length > 0)
 183        {
 0184            var excludeTypeNames = MapKindsToTypeNames(excludeItemTypes);
 0185            if (excludeTypeNames.Count > 0)
 186            {
 0187                query = query.Where(e => !excludeTypeNames.Contains(e.Type));
 188            }
 189        }
 190
 2191        return query;
 192    }
 193
 194    private static IQueryable<BaseItemEntity> ApplyMediaTypeFilter(
 195        IQueryable<BaseItemEntity> query,
 196        MediaType[] mediaTypes)
 197    {
 2198        if (mediaTypes.Length == 0)
 199        {
 2200            return query;
 201        }
 202
 0203        var mediaTypeNames = mediaTypes.Select(m => m.ToString()).ToArray();
 0204        return query.Where(e => e.MediaType != null && mediaTypeNames.Contains(e.MediaType));
 205    }
 206
 207    private static IQueryable<BaseItemEntity> ApplyParentFilter(
 208        IQueryable<BaseItemEntity> query,
 209        Guid? parentId)
 210    {
 2211        if (!parentId.HasValue || parentId.Value.IsEmpty())
 212        {
 2213            return query;
 214        }
 215
 0216        var pid = parentId.Value;
 0217        return query.Where(e => e.ParentId == pid || e.Parents!.Any(p => p.ParentItemId == pid));
 218    }
 219
 220    private static IQueryable<BaseItemEntity> ExcludeVersionsOfMatchedPrimaries(IQueryable<BaseItemEntity> query)
 221    {
 2222        var matched = query;
 2223        return query.Where(e => e.PrimaryVersionId == null || !matched.Any(p => p.Id == e.PrimaryVersionId));
 224    }
 225
 226    private IQueryable<BaseItemEntity> ApplyUserAccessFilter(
 227        JellyfinDbContext dbContext,
 228        IQueryable<BaseItemEntity> query,
 229        SearchProviderQuery searchQuery)
 230    {
 2231        var userId = searchQuery.UserId;
 2232        if (!userId.HasValue || userId.Value.IsEmpty())
 233        {
 0234            return query;
 235        }
 236
 2237        var user = _userManager.GetUserById(userId.Value);
 2238        if (user is null)
 239        {
 0240            return query;
 241        }
 242
 2243        var accessFilter = SearchQueryAccessFilter.Build(user, searchQuery, _libraryManager);
 2244        return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter);
 245    }
 246
 247    private List<string> MapKindsToTypeNames(BaseItemKind[] kinds)
 248    {
 0249        var list = new List<string>(kinds.Length);
 0250        foreach (var kind in kinds)
 251        {
 0252            if (_itemTypeLookup.BaseItemKindNames.TryGetValue(kind, out var name) && name is not null)
 253            {
 0254                list.Add(name);
 255            }
 256        }
 257
 0258        return list;
 259    }
 260}