< 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: 458
Line coverage: 8.2%
Branch coverage
0%
Covered branches: 0
Total branches: 96
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: 458 6/8/2026 - 12:16:15 AM Line coverage: 8.2% (17/205) Branch coverage: 0% (0/96) Total lines: 458

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;
 2131    private IExternalSearchProvider[] _externalProviders = [];
 2132    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    {
 2149        _libraryManager = libraryManager;
 2150        _userManager = userManager;
 2151        _dbProvider = dbProvider;
 2152        _queryHelpers = queryHelpers;
 2153        _logger = logger;
 2154    }
 55
 56    /// <inheritdoc/>
 57    public void AddParts(IEnumerable<ISearchProvider> providers)
 58    {
 2159        var allProviders = providers.OrderBy(p => p.Priority).ToArray();
 60
 2161        _externalProviders = allProviders.OfType<IExternalSearchProvider>().ToArray();
 2162        _internalProviders = allProviders.OfType<IInternalSearchProvider>().ToArray();
 63
 2164        _logger.LogInformation(
 2165            "Registered {ExternalCount} external search providers: {ExternalProviders}. Fallback providers: {FallbackPro
 2166            _externalProviders.Length,
 2167            string.Join(", ", _externalProviders.Select(p => $"{p.Name} (priority {p.Priority})")),
 2168            string.Join(", ", _internalProviders.Select(p => $"{p.Name} (priority {p.Priority})")));
 2169    }
 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);
 095        var fromExternal = externalResults.Count > 0;
 96        IReadOnlyList<SearchResult> results;
 097        if (fromExternal)
 98        {
 099            results = externalResults;
 100        }
 101        else
 102        {
 0103            results = await internalTask.ConfigureAwait(false);
 0104            if (_internalProviders.Length > 0)
 105            {
 0106                _logger.LogDebug("No results from external providers, using internal provider results");
 107            }
 108        }
 109
 110        // Internal providers apply user-access filtering inline in their queries. External
 111        // providers don't know about user permissions, so they may return IDs from hidden
 112        // libraries or items the user is otherwise blocked from. Run the post-filter only
 113        // when results came from externals to close that gap. The Items controller's second
 114        // roundtrip via folder.GetItems applies most of these again, but it does not restrict
 115        // by TopParentIds when ItemIds is set.
 0116        if (fromExternal && results.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty())
 117        {
 0118            var user = _userManager.GetUserById(query.UserId.Value);
 0119            if (user is not null)
 120            {
 0121                results = await FilterByUserAccessAsync(results, user, cancellationToken).ConfigureAwait(false);
 122            }
 123        }
 124
 0125        return results;
 0126    }
 127
 128    private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
 129        IReadOnlyList<SearchResult> candidates,
 130        User user,
 131        CancellationToken cancellationToken)
 132    {
 133        // SetUser populates parental rating + blocked/allowed tags. ConfigureUserAccess populates
 134        // TopParentIds for the user's accessible libraries — we call it before assigning ItemIds
 135        // because LibraryManager.AddUserToQuery skips TopParentIds when ItemIds is non-empty.
 0136        var accessFilter = new InternalItemsQuery(user);
 0137        _libraryManager.ConfigureUserAccess(accessFilter, user);
 138
 0139        Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)];
 140
 0141        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 0142        await using (dbContext.ConfigureAwait(false))
 143        {
 0144            var baseQuery = dbContext.BaseItems
 0145                .AsNoTracking()
 0146                .WhereOneOrMany(candidateIds, e => e.Id);
 147
 0148            baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
 149
 0150            var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false);
 0151            if (allowedCount == candidates.Count)
 152            {
 0153                return candidates;
 154            }
 155
 0156            var allowedIds = await baseQuery
 0157                .Select(e => e.Id)
 0158                .ToHashSetAsync(cancellationToken)
 0159                .ConfigureAwait(false);
 160
 0161            var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList();
 0162            if (filtered.Count < candidates.Count)
 163            {
 0164                _logger.LogDebug(
 0165                    "Dropped {Dropped} of {Total} search candidates due to user access filtering",
 0166                    candidates.Count - filtered.Count,
 0167                    candidates.Count);
 168            }
 169
 0170            return filtered;
 171        }
 0172    }
 173
 174    /// <inheritdoc/>
 175    public async Task<QueryResult<SearchHintInfo>> GetSearchHintsAsync(SearchQuery query, CancellationToken cancellation
 176    {
 0177        ArgumentNullException.ThrowIfNull(query);
 0178        ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
 179
 0180        var providerQuery = BuildProviderQuery(query);
 0181        var candidates = await GetSearchResultsAsync(providerQuery, cancellationToken).ConfigureAwait(false);
 0182        if (candidates.Count == 0)
 183        {
 0184            return new QueryResult<SearchHintInfo>();
 185        }
 186
 0187        var candidateScores = BuildScoreLookup(candidates);
 0188        var user = query.UserId.IsEmpty() ? null : _userManager.GetUserById(query.UserId);
 189
 0190        var excludeItemTypes = BuildExcludeItemTypes(query);
 0191        var includeItemTypes = BuildIncludeItemTypes(query);
 192
 0193        var internalQuery = new InternalItemsQuery(user)
 0194        {
 0195            ItemIds = candidateScores.Keys.ToArray(),
 0196            ExcludeItemTypes = excludeItemTypes.ToArray(),
 0197            IncludeItemTypes = includeItemTypes.Count > 0 ? includeItemTypes.ToArray() : [],
 0198            MediaTypes = query.MediaTypes.ToArray(),
 0199            IncludeItemsByName = !query.ParentId.HasValue,
 0200            ParentId = query.ParentId ?? Guid.Empty,
 0201            Recursive = true,
 0202            IsKids = query.IsKids,
 0203            IsMovie = query.IsMovie,
 0204            IsNews = query.IsNews,
 0205            IsSeries = query.IsSeries,
 0206            IsSports = query.IsSports,
 0207            DtoOptions = new DtoOptions
 0208            {
 0209                Fields =
 0210                [
 0211                    ItemFields.AirTime,
 0212                    ItemFields.DateCreated,
 0213                    ItemFields.ChannelInfo,
 0214                    ItemFields.ParentId
 0215                ]
 0216            }
 0217        };
 218
 219        // MusicArtist items are "ItemsByName" entities - virtual items that aggregate content by artist name
 220        // rather than being stored as regular library items. They require special handling:
 221        // 1. Convert ParentId to AncestorIds (to filter by library folder)
 222        // 2. Set IncludeItemsByName = true (to include these virtual items in results)
 223        // 3. Clear IncludeItemTypes (GetAllArtists handles type filtering internally)
 224        // 4. Use GetAllArtists() instead of GetItemList() to query the artist index
 225        IReadOnlyList<BaseItem> items;
 0226        if (internalQuery.IncludeItemTypes.Length == 1 && internalQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist)
 227        {
 0228            if (!internalQuery.ParentId.IsEmpty())
 229            {
 0230                internalQuery.AncestorIds = [internalQuery.ParentId];
 0231                internalQuery.ParentId = Guid.Empty;
 232            }
 233
 0234            internalQuery.IncludeItemsByName = true;
 0235            internalQuery.IncludeItemTypes = [];
 0236            items = _libraryManager.GetAllArtists(internalQuery).Items.Select(i => i.Item).ToList();
 237        }
 238        else
 239        {
 0240            items = _libraryManager.GetItemList(internalQuery);
 241        }
 242
 0243        var orderedResults = items
 0244            .Select(item => new SearchHintInfo { Item = item })
 0245            .OrderByDescending(hint => candidateScores.GetValueOrDefault(hint.Item.Id, 0f))
 0246            .ToList();
 247
 0248        var totalCount = orderedResults.Count;
 249
 0250        if (query.StartIndex.HasValue)
 251        {
 0252            orderedResults = orderedResults.Skip(query.StartIndex.Value).ToList();
 253        }
 254
 0255        if (query.Limit.HasValue)
 256        {
 0257            orderedResults = orderedResults.Take(query.Limit.Value).ToList();
 258        }
 259
 0260        return new QueryResult<SearchHintInfo>(query.StartIndex, totalCount, orderedResults);
 0261    }
 262
 263    private async Task<IReadOnlyList<SearchResult>> CollectFromProvidersAsync(
 264        IEnumerable<ISearchProvider> providers,
 265        SearchProviderQuery providerQuery,
 266        string searchTerm,
 267        CancellationToken cancellationToken)
 268    {
 0269        var requestedLimit = providerQuery.Limit ?? 100;
 0270        var applicable = providers.Where(p => p.CanSearch(providerQuery)).ToArray();
 0271        if (applicable.Length == 0)
 272        {
 0273            return [];
 274        }
 275
 0276        var perProvider = await Task.WhenAll(
 0277            applicable.Select(p => CollectFromProviderAsync(p, providerQuery, searchTerm, requestedLimit, cancellationTo
 0278            .ConfigureAwait(false);
 279
 0280        var bestScores = new Dictionary<Guid, float>();
 0281        foreach (var providerResults in perProvider)
 282        {
 0283            foreach (var result in providerResults)
 284            {
 0285                UpdateBestScore(bestScores, result);
 286            }
 287        }
 288
 0289        return bestScores
 0290            .Select(kvp => new SearchResult(kvp.Key, kvp.Value))
 0291            .OrderByDescending(r => r.Score)
 0292            .Take(requestedLimit)
 0293            .ToList();
 0294    }
 295
 296    private async Task<IReadOnlyList<SearchResult>> CollectFromProviderAsync(
 297        ISearchProvider provider,
 298        SearchProviderQuery providerQuery,
 299        string searchTerm,
 300        int requestedLimit,
 301        CancellationToken cancellationToken)
 302    {
 303        try
 304        {
 0305            var results = provider is IExternalSearchProvider externalProvider
 0306                ? await CollectFromExternalProviderAsync(externalProvider, providerQuery, requestedLimit, cancellationTo
 0307                : await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false);
 308
 0309            _logger.LogDebug(
 0310                "Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'",
 0311                provider.Name,
 0312                results.Count,
 0313                searchTerm);
 0314            return results;
 315        }
 0316        catch (Exception ex)
 317        {
 0318            _logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTer
 0319            return [];
 320        }
 0321    }
 322
 323    private static async Task<IReadOnlyList<SearchResult>> CollectFromExternalProviderAsync(
 324        IExternalSearchProvider provider,
 325        SearchProviderQuery providerQuery,
 326        int requestedLimit,
 327        CancellationToken cancellationToken)
 328    {
 0329        var results = new List<SearchResult>();
 0330        await foreach (var result in provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false))
 331        {
 0332            results.Add(result);
 0333            if (results.Count >= requestedLimit)
 334            {
 335                break;
 336            }
 337        }
 338
 0339        return results;
 0340    }
 341
 342    private static void UpdateBestScore(Dictionary<Guid, float> bestScores, SearchResult result)
 343    {
 0344        if (!bestScores.TryGetValue(result.ItemId, out var existingScore) || result.Score > existingScore)
 345        {
 0346            bestScores[result.ItemId] = result.Score;
 347        }
 0348    }
 349
 350    private static Dictionary<Guid, float> BuildScoreLookup(IReadOnlyList<SearchResult> results)
 351    {
 0352        var lookup = new Dictionary<Guid, float>(results.Count);
 0353        foreach (var result in results)
 354        {
 0355            lookup[result.ItemId] = result.Score;
 356        }
 357
 0358        return lookup;
 359    }
 360
 361    private static SearchProviderQuery BuildProviderQuery(SearchQuery query)
 362    {
 0363        var excludeItemTypes = BuildExcludeItemTypes(query);
 0364        var includeItemTypes = BuildIncludeItemTypes(query);
 365
 366        // Remove any excluded types from includes
 0367        if (includeItemTypes.Count > 0 && excludeItemTypes.Count > 0)
 368        {
 0369            includeItemTypes.RemoveAll(excludeItemTypes.Contains);
 370        }
 371
 0372        return new SearchProviderQuery
 0373        {
 0374            SearchTerm = query.SearchTerm,
 0375            UserId = query.UserId.IsEmpty() ? null : query.UserId,
 0376            IncludeItemTypes = includeItemTypes.ToArray(),
 0377            ExcludeItemTypes = excludeItemTypes.ToArray(),
 0378            MediaTypes = query.MediaTypes.ToArray(),
 0379            Limit = query.Limit,
 0380            ParentId = query.ParentId
 0381        };
 382    }
 383
 384    private static List<BaseItemKind> BuildExcludeItemTypes(SearchQuery query)
 385    {
 0386        var excludeItemTypes = query.ExcludeItemTypes.ToList();
 387
 0388        excludeItemTypes.Add(BaseItemKind.Year);
 0389        excludeItemTypes.Add(BaseItemKind.Folder);
 0390        excludeItemTypes.Add(BaseItemKind.CollectionFolder);
 391
 0392        if (!query.IncludeGenres)
 393        {
 0394            AddIfMissing(excludeItemTypes, BaseItemKind.Genre);
 0395            AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre);
 396        }
 397
 0398        if (!query.IncludePeople)
 399        {
 0400            AddIfMissing(excludeItemTypes, BaseItemKind.Person);
 401        }
 402
 0403        if (!query.IncludeStudios)
 404        {
 0405            AddIfMissing(excludeItemTypes, BaseItemKind.Studio);
 406        }
 407
 0408        if (!query.IncludeArtists)
 409        {
 0410            AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist);
 411        }
 412
 0413        return excludeItemTypes;
 414    }
 415
 416    private static List<BaseItemKind> BuildIncludeItemTypes(SearchQuery query)
 417    {
 0418        var includeItemTypes = query.IncludeItemTypes.ToList();
 0419        if (query.IncludeMedia)
 420        {
 0421            return includeItemTypes;
 422        }
 423
 0424        if (query.IncludeGenres && IsEmptyOrContains(includeItemTypes, BaseItemKind.Genre))
 425        {
 0426            AddIfMissing(includeItemTypes, BaseItemKind.Genre);
 0427            AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre);
 428        }
 429
 0430        if (query.IncludePeople && IsEmptyOrContains(includeItemTypes, BaseItemKind.Person))
 431        {
 0432            AddIfMissing(includeItemTypes, BaseItemKind.Person);
 433        }
 434
 0435        if (query.IncludeStudios && IsEmptyOrContains(includeItemTypes, BaseItemKind.Studio))
 436        {
 0437            AddIfMissing(includeItemTypes, BaseItemKind.Studio);
 438        }
 439
 0440        if (query.IncludeArtists && IsEmptyOrContains(includeItemTypes, BaseItemKind.MusicArtist))
 441        {
 0442            AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist);
 443        }
 444
 0445        return includeItemTypes;
 446    }
 447
 448    private static bool IsEmptyOrContains(List<BaseItemKind> list, BaseItemKind value)
 0449        => list.Count == 0 || list.Contains(value);
 450
 451    private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value)
 452    {
 0453        if (!list.Contains(value))
 454        {
 0455            list.Add(value);
 456        }
 0457    }
 458}