< Summary - Jellyfin

Information
Class: Emby.Server.Implementations.Library.Search.SearchManager
Assembly: Emby.Server.Implementations
File(s): /srv/git/jellyfin/Emby.Server.Implementations/Library/Search/SearchManager.cs
Line coverage
8%
Covered lines: 17
Uncovered lines: 188
Coverable lines: 205
Total lines: 461
Line coverage: 8.2%
Branch coverage
0%
Covered branches: 0
Total branches: 94
Branch coverage: 0%
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: 8.2% (17/205) Branch coverage: 0% (0/96) Total lines: 4588/6/2026 - 12:17:15 AM Line coverage: 8.3% (17/204) Branch coverage: 0% (0/96) Total lines: 4598/11/2026 - 12:17:00 AM Line coverage: 8.3% (17/203) Branch coverage: 0% (0/94) Total lines: 45510/29/2026 - 12:17:11 AM Line coverage: 8.2% (17/205) Branch coverage: 0% (0/94) Total lines: 461 6/8/2026 - 12:16:15 AM Line coverage: 8.2% (17/205) Branch coverage: 0% (0/96) Total lines: 4588/6/2026 - 12:17:15 AM Line coverage: 8.3% (17/204) Branch coverage: 0% (0/96) Total lines: 4598/11/2026 - 12:17:00 AM Line coverage: 8.3% (17/203) Branch coverage: 0% (0/94) Total lines: 45510/29/2026 - 12:17:11 AM Line coverage: 8.2% (17/205) Branch coverage: 0% (0/94) Total lines: 461

Coverage delta

Coverage delta 1 -1

Metrics

File(s)

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

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Linq;
 4using System.Threading;
 5using System.Threading.Tasks;
 6using Jellyfin.Data.Enums;
 7using Jellyfin.Database.Implementations;
 8using Jellyfin.Database.Implementations.Entities;
 9using Jellyfin.Extensions;
 10using MediaBrowser.Controller.Dto;
 11using MediaBrowser.Controller.Entities;
 12using MediaBrowser.Controller.Library;
 13using MediaBrowser.Controller.Persistence;
 14using MediaBrowser.Model.Querying;
 15using MediaBrowser.Model.Search;
 16using Microsoft.EntityFrameworkCore;
 17using Microsoft.Extensions.Logging;
 18
 19namespace Emby.Server.Implementations.Library.Search;
 20
 21/// <summary>
 22/// Manages search providers and orchestrates search operations.
 23/// </summary>
 24public class SearchManager : ISearchManager
 25{
 26    private readonly ILibraryManager _libraryManager;
 27    private readonly IUserManager _userManager;
 28    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
 29    private readonly IItemQueryHelpers _queryHelpers;
 30    private readonly ILogger<SearchManager> _logger;
 2731    private IExternalSearchProvider[] _externalProviders = [];
 2732    private IInternalSearchProvider[] _internalProviders = [];
 33
 34    /// <summary>
 35    /// Initializes a new instance of the <see cref="SearchManager"/> class.
 36    /// </summary>
 37    /// <param name="libraryManager">The library manager.</param>
 38    /// <param name="userManager">The user manager.</param>
 39    /// <param name="dbProvider">The database context factory.</param>
 40    /// <param name="queryHelpers">The shared item query helpers.</param>
 41    /// <param name="logger">The logger.</param>
 42    public SearchManager(
 43        ILibraryManager libraryManager,
 44        IUserManager userManager,
 45        IDbContextFactory<JellyfinDbContext> dbProvider,
 46        IItemQueryHelpers queryHelpers,
 47        ILogger<SearchManager> logger)
 48    {
 2749        _libraryManager = libraryManager;
 2750        _userManager = userManager;
 2751        _dbProvider = dbProvider;
 2752        _queryHelpers = queryHelpers;
 2753        _logger = logger;
 2754    }
 55
 56    /// <inheritdoc/>
 57    public void AddParts(IEnumerable<ISearchProvider> providers)
 58    {
 2759        var allProviders = providers.OrderBy(p => p.Priority).ToArray();
 60
 2761        _externalProviders = allProviders.OfType<IExternalSearchProvider>().ToArray();
 2762        _internalProviders = allProviders.OfType<IInternalSearchProvider>().ToArray();
 63
 2764        _logger.LogInformation(
 2765            "Registered {ExternalCount} external search providers: {ExternalProviders}. Fallback providers: {FallbackPro
 2766            _externalProviders.Length,
 2767            string.Join(", ", _externalProviders.Select(p => $"{p.Name} (priority {p.Priority})")),
 2768            string.Join(", ", _internalProviders.Select(p => $"{p.Name} (priority {p.Priority})")));
 2769    }
 70
 71    /// <inheritdoc/>
 72    public IReadOnlyList<ISearchProvider> GetProviders()
 73    {
 074        return [.. _externalProviders, .. _internalProviders];
 75    }
 76
 77    /// <inheritdoc/>
 78    public async Task<IReadOnlyList<SearchResult>> GetSearchResultsAsync(
 79        SearchProviderQuery query,
 80        CancellationToken cancellationToken = default)
 81    {
 082        ArgumentNullException.ThrowIfNull(query);
 083        ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
 84
 085        var searchTerm = query.SearchTerm.Trim().RemoveDiacritics();
 86
 087        var externalTask = CollectFromProvidersAsync(_externalProviders, query, searchTerm, cancellationToken);
 088        var internalTask = _internalProviders.Length > 0
 089            ? CollectFromProvidersAsync(_internalProviders, query, searchTerm, cancellationToken)
 090            : Task.FromResult<IReadOnlyList<SearchResult>>([]);
 91
 092        await Task.WhenAll(externalTask, internalTask).ConfigureAwait(false);
 93
 094        var externalResults = await externalTask.ConfigureAwait(false);
 95
 96        // Internal providers apply user-access filtering inline in their queries. External
 97        // providers don't know about user permissions, so they may return IDs from hidden
 98        // libraries or items the user is otherwise blocked from. Filter them here to close
 99        // that gap. The Items controller's second roundtrip via folder.GetItems applies most
 100        // of these again, but it does not restrict by TopParentIds when ItemIds is set.
 0101        if (externalResults.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty())
 102        {
 0103            var user = _userManager.GetUserById(query.UserId.Value);
 0104            if (user is not null)
 105            {
 0106                externalResults = await FilterByUserAccessAsync(externalResults, user, query, cancellationToken).Configu
 107            }
 108        }
 109
 0110        if (externalResults.Count > 0)
 111        {
 0112            return externalResults;
 113        }
 114
 0115        if (_internalProviders.Length > 0)
 116        {
 0117            _logger.LogDebug("No results from external providers, using internal provider results");
 118        }
 119
 0120        return await internalTask.ConfigureAwait(false);
 0121    }
 122
 123    private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
 124        IReadOnlyList<SearchResult> candidates,
 125        User user,
 126        SearchProviderQuery query,
 127        CancellationToken cancellationToken)
 128    {
 129        // SetUser populates parental rating + blocked/allowed tags, Build populates TopParentIds
 130        // for the user's accessible libraries. The candidate ids are applied to the query below
 131        // rather than to the filter because LibraryManager.AddUserToQuery skips TopParentIds when
 132        // ItemIds is non-empty.
 0133        var accessFilter = SearchQueryAccessFilter.Build(user, query, _libraryManager);
 134
 0135        Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)];
 136
 0137        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 0138        await using (dbContext.ConfigureAwait(false))
 139        {
 0140            var baseQuery = dbContext.BaseItems
 0141                .AsNoTracking()
 0142                .WhereOneOrMany(candidateIds, e => e.Id);
 143
 0144            baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
 145
 0146            var allowed = await baseQuery
 0147                .Select(e => new { e.Id, e.PrimaryVersionId })
 0148                .ToListAsync(cancellationToken)
 0149                .ConfigureAwait(false);
 150
 0151            var allowedIds = allowed.Select(e => e.Id).ToHashSet();
 152
 153            // A provider can return both an alternate version and the primary it belongs to, and the
 154            // two are one item to the user.
 0155            allowedIds.ExceptWith(allowed
 0156                .Where(e => e.PrimaryVersionId.HasValue && allowedIds.Contains(e.PrimaryVersionId.Value))
 0157                .Select(e => e.Id));
 158
 0159            if (allowedIds.Count == candidates.Count)
 160            {
 0161                return candidates;
 162            }
 163
 0164            var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList();
 0165            if (filtered.Count < candidates.Count)
 166            {
 0167                _logger.LogDebug(
 0168                    "Dropped {Dropped} of {Total} search candidates due to user access filtering",
 0169                    candidates.Count - filtered.Count,
 0170                    candidates.Count);
 171            }
 172
 0173            return filtered;
 174        }
 0175    }
 176
 177    /// <inheritdoc/>
 178    public async Task<QueryResult<SearchHintInfo>> GetSearchHintsAsync(SearchQuery query, CancellationToken cancellation
 179    {
 0180        ArgumentNullException.ThrowIfNull(query);
 0181        ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
 182
 0183        var providerQuery = BuildProviderQuery(query);
 0184        var candidates = await GetSearchResultsAsync(providerQuery, cancellationToken).ConfigureAwait(false);
 0185        if (candidates.Count == 0)
 186        {
 0187            return new QueryResult<SearchHintInfo>();
 188        }
 189
 0190        var candidateScores = BuildScoreLookup(candidates);
 0191        var user = query.UserId.IsEmpty() ? null : _userManager.GetUserById(query.UserId);
 192
 0193        var excludeItemTypes = BuildExcludeItemTypes(query);
 0194        var includeItemTypes = BuildIncludeItemTypes(query);
 195
 0196        var internalQuery = new InternalItemsQuery(user)
 0197        {
 0198            ItemIds = candidateScores.Keys.ToArray(),
 0199            ExcludeItemTypes = excludeItemTypes.ToArray(),
 0200            IncludeItemTypes = includeItemTypes.Count > 0 ? includeItemTypes.ToArray() : [],
 0201            MediaTypes = query.MediaTypes.ToArray(),
 0202            IncludeItemsByName = !query.ParentId.HasValue,
 0203            ParentId = query.ParentId ?? Guid.Empty,
 0204            Recursive = true,
 0205            IsKids = query.IsKids,
 0206            IsMovie = query.IsMovie,
 0207            IsNews = query.IsNews,
 0208            IsSeries = query.IsSeries,
 0209            IsSports = query.IsSports,
 0210            DtoOptions = new DtoOptions
 0211            {
 0212                Fields =
 0213                [
 0214                    ItemFields.AirTime,
 0215                    ItemFields.DateCreated,
 0216                    ItemFields.ChannelInfo,
 0217                    ItemFields.ParentId
 0218                ]
 0219            }
 0220        };
 221
 222        // MusicArtist items are "ItemsByName" entities - virtual items that aggregate content by artist name
 223        // rather than being stored as regular library items. They require special handling:
 224        // 1. Convert ParentId to AncestorIds (to filter by library folder)
 225        // 2. Set IncludeItemsByName = true (to include these virtual items in results)
 226        // 3. Clear IncludeItemTypes (GetAllArtists handles type filtering internally)
 227        // 4. Use GetAllArtists() instead of GetItemList() to query the artist index
 228        IReadOnlyList<BaseItem> items;
 0229        if (internalQuery.IncludeItemTypes.Length == 1 && internalQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist)
 230        {
 0231            if (!internalQuery.ParentId.IsEmpty())
 232            {
 0233                internalQuery.AncestorIds = [internalQuery.ParentId];
 0234                internalQuery.ParentId = Guid.Empty;
 235            }
 236
 0237            internalQuery.IncludeItemsByName = true;
 0238            internalQuery.IncludeItemTypes = [];
 0239            items = _libraryManager.GetAllArtists(internalQuery).Items.Select(i => i.Item).ToList();
 240        }
 241        else
 242        {
 0243            items = _libraryManager.GetItemList(internalQuery);
 244        }
 245
 0246        var orderedResults = items
 0247            .Select(item => new SearchHintInfo { Item = item })
 0248            .OrderByDescending(hint => candidateScores.GetValueOrDefault(hint.Item.Id, 0f))
 0249            .ToList();
 250
 0251        var totalCount = orderedResults.Count;
 252
 0253        if (query.StartIndex.HasValue)
 254        {
 0255            orderedResults = orderedResults.Skip(query.StartIndex.Value).ToList();
 256        }
 257
 0258        if (query.Limit.HasValue)
 259        {
 0260            orderedResults = orderedResults.Take(query.Limit.Value).ToList();
 261        }
 262
 0263        return new QueryResult<SearchHintInfo>(query.StartIndex, totalCount, orderedResults);
 0264    }
 265
 266    private async Task<IReadOnlyList<SearchResult>> CollectFromProvidersAsync(
 267        IEnumerable<ISearchProvider> providers,
 268        SearchProviderQuery providerQuery,
 269        string searchTerm,
 270        CancellationToken cancellationToken)
 271    {
 0272        var requestedLimit = providerQuery.Limit ?? 100;
 0273        var applicable = providers.Where(p => p.CanSearch(providerQuery)).ToArray();
 0274        if (applicable.Length == 0)
 275        {
 0276            return [];
 277        }
 278
 0279        var perProvider = await Task.WhenAll(
 0280            applicable.Select(p => CollectFromProviderAsync(p, providerQuery, searchTerm, requestedLimit, cancellationTo
 0281            .ConfigureAwait(false);
 282
 0283        var bestScores = new Dictionary<Guid, float>();
 0284        foreach (var providerResults in perProvider)
 285        {
 0286            foreach (var result in providerResults)
 287            {
 0288                UpdateBestScore(bestScores, result);
 289            }
 290        }
 291
 0292        return bestScores
 0293            .Select(kvp => new SearchResult(kvp.Key, kvp.Value))
 0294            .OrderByDescending(r => r.Score)
 0295            .Take(requestedLimit)
 0296            .ToList();
 0297    }
 298
 299    private async Task<IReadOnlyList<SearchResult>> CollectFromProviderAsync(
 300        ISearchProvider provider,
 301        SearchProviderQuery providerQuery,
 302        string searchTerm,
 303        int requestedLimit,
 304        CancellationToken cancellationToken)
 305    {
 306        try
 307        {
 0308            var results = provider is IExternalSearchProvider externalProvider
 0309                ? await CollectFromExternalProviderAsync(externalProvider, providerQuery, requestedLimit, cancellationTo
 0310                : await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false);
 311
 0312            _logger.LogDebug(
 0313                "Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'",
 0314                provider.Name,
 0315                results.Count,
 0316                searchTerm);
 0317            return results;
 318        }
 0319        catch (Exception ex)
 320        {
 0321            _logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTer
 0322            return [];
 323        }
 0324    }
 325
 326    private static async Task<IReadOnlyList<SearchResult>> CollectFromExternalProviderAsync(
 327        IExternalSearchProvider provider,
 328        SearchProviderQuery providerQuery,
 329        int requestedLimit,
 330        CancellationToken cancellationToken)
 331    {
 0332        var results = new List<SearchResult>();
 0333        await foreach (var result in provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false))
 334        {
 0335            results.Add(result);
 0336            if (results.Count >= requestedLimit)
 337            {
 338                break;
 339            }
 340        }
 341
 0342        return results;
 0343    }
 344
 345    private static void UpdateBestScore(Dictionary<Guid, float> bestScores, SearchResult result)
 346    {
 0347        if (!bestScores.TryGetValue(result.ItemId, out var existingScore) || result.Score > existingScore)
 348        {
 0349            bestScores[result.ItemId] = result.Score;
 350        }
 0351    }
 352
 353    private static Dictionary<Guid, float> BuildScoreLookup(IReadOnlyList<SearchResult> results)
 354    {
 0355        var lookup = new Dictionary<Guid, float>(results.Count);
 0356        foreach (var result in results)
 357        {
 0358            lookup[result.ItemId] = result.Score;
 359        }
 360
 0361        return lookup;
 362    }
 363
 364    private static SearchProviderQuery BuildProviderQuery(SearchQuery query)
 365    {
 0366        var excludeItemTypes = BuildExcludeItemTypes(query);
 0367        var includeItemTypes = BuildIncludeItemTypes(query);
 368
 369        // Remove any excluded types from includes
 0370        if (includeItemTypes.Count > 0 && excludeItemTypes.Count > 0)
 371        {
 0372            includeItemTypes.RemoveAll(excludeItemTypes.Contains);
 373        }
 374
 0375        return new SearchProviderQuery
 0376        {
 0377            SearchTerm = query.SearchTerm,
 0378            UserId = query.UserId.IsEmpty() ? null : query.UserId,
 0379            IncludeItemTypes = includeItemTypes.ToArray(),
 0380            ExcludeItemTypes = excludeItemTypes.ToArray(),
 0381            MediaTypes = query.MediaTypes.ToArray(),
 0382            Limit = query.Limit,
 0383            ParentId = query.ParentId
 0384        };
 385    }
 386
 387    private static List<BaseItemKind> BuildExcludeItemTypes(SearchQuery query)
 388    {
 0389        var excludeItemTypes = query.ExcludeItemTypes.ToList();
 390
 0391        excludeItemTypes.Add(BaseItemKind.Year);
 0392        excludeItemTypes.Add(BaseItemKind.Folder);
 0393        excludeItemTypes.Add(BaseItemKind.CollectionFolder);
 394
 0395        if (!query.IncludeGenres)
 396        {
 0397            AddIfMissing(excludeItemTypes, BaseItemKind.Genre);
 0398            AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre);
 399        }
 400
 0401        if (!query.IncludePeople)
 402        {
 0403            AddIfMissing(excludeItemTypes, BaseItemKind.Person);
 404        }
 405
 0406        if (!query.IncludeStudios)
 407        {
 0408            AddIfMissing(excludeItemTypes, BaseItemKind.Studio);
 409        }
 410
 0411        if (!query.IncludeArtists)
 412        {
 0413            AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist);
 414        }
 415
 0416        return excludeItemTypes;
 417    }
 418
 419    private static List<BaseItemKind> BuildIncludeItemTypes(SearchQuery query)
 420    {
 0421        var includeItemTypes = query.IncludeItemTypes.ToList();
 0422        if (query.IncludeMedia)
 423        {
 0424            return includeItemTypes;
 425        }
 426
 0427        if (query.IncludeGenres && IsEmptyOrContains(includeItemTypes, BaseItemKind.Genre))
 428        {
 0429            AddIfMissing(includeItemTypes, BaseItemKind.Genre);
 0430            AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre);
 431        }
 432
 0433        if (query.IncludePeople && IsEmptyOrContains(includeItemTypes, BaseItemKind.Person))
 434        {
 0435            AddIfMissing(includeItemTypes, BaseItemKind.Person);
 436        }
 437
 0438        if (query.IncludeStudios && IsEmptyOrContains(includeItemTypes, BaseItemKind.Studio))
 439        {
 0440            AddIfMissing(includeItemTypes, BaseItemKind.Studio);
 441        }
 442
 0443        if (query.IncludeArtists && IsEmptyOrContains(includeItemTypes, BaseItemKind.MusicArtist))
 444        {
 0445            AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist);
 446        }
 447
 0448        return includeItemTypes;
 449    }
 450
 451    private static bool IsEmptyOrContains(List<BaseItemKind> list, BaseItemKind value)
 0452        => list.Count == 0 || list.Contains(value);
 453
 454    private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value)
 455    {
 0456        if (!list.Contains(value))
 457        {
 0458            list.Add(value);
 459        }
 0460    }
 461}