< 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: 186
Coverable lines: 203
Total lines: 455
Line coverage: 8.3%
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: 455 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: 455

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;
 2231    private IExternalSearchProvider[] _externalProviders = [];
 2232    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    {
 2249        _libraryManager = libraryManager;
 2250        _userManager = userManager;
 2251        _dbProvider = dbProvider;
 2252        _queryHelpers = queryHelpers;
 2253        _logger = logger;
 2254    }
 55
 56    /// <inheritdoc/>
 57    public void AddParts(IEnumerable<ISearchProvider> providers)
 58    {
 2259        var allProviders = providers.OrderBy(p => p.Priority).ToArray();
 60
 2261        _externalProviders = allProviders.OfType<IExternalSearchProvider>().ToArray();
 2262        _internalProviders = allProviders.OfType<IInternalSearchProvider>().ToArray();
 63
 2264        _logger.LogInformation(
 2265            "Registered {ExternalCount} external search providers: {ExternalProviders}. Fallback providers: {FallbackPro
 2266            _externalProviders.Length,
 2267            string.Join(", ", _externalProviders.Select(p => $"{p.Name} (priority {p.Priority})")),
 2268            string.Join(", ", _internalProviders.Select(p => $"{p.Name} (priority {p.Priority})")));
 2269    }
 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        var internalResults = await internalTask.ConfigureAwait(false);
 0116        if (_internalProviders.Length > 0)
 117        {
 0118            _logger.LogDebug("No results from external providers, using internal provider results");
 119        }
 120
 0121        return internalResults;
 0122    }
 123
 124    private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
 125        IReadOnlyList<SearchResult> candidates,
 126        User user,
 127        SearchProviderQuery query,
 128        CancellationToken cancellationToken)
 129    {
 130        // SetUser populates parental rating + blocked/allowed tags, Build populates TopParentIds
 131        // for the user's accessible libraries. The candidate ids are applied to the query below
 132        // rather than to the filter because LibraryManager.AddUserToQuery skips TopParentIds when
 133        // ItemIds is non-empty.
 0134        var accessFilter = SearchQueryAccessFilter.Build(user, query, _libraryManager);
 135
 0136        Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)];
 137
 0138        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
 0139        await using (dbContext.ConfigureAwait(false))
 140        {
 0141            var baseQuery = dbContext.BaseItems
 0142                .AsNoTracking()
 0143                .WhereOneOrMany(candidateIds, e => e.Id);
 144
 0145            baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
 146
 0147            var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false);
 0148            if (allowedCount == candidates.Count)
 149            {
 0150                return candidates;
 151            }
 152
 0153            var allowedIds = await baseQuery
 0154                .Select(e => e.Id)
 0155                .ToHashSetAsync(cancellationToken)
 0156                .ConfigureAwait(false);
 157
 0158            var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList();
 0159            if (filtered.Count < candidates.Count)
 160            {
 0161                _logger.LogDebug(
 0162                    "Dropped {Dropped} of {Total} search candidates due to user access filtering",
 0163                    candidates.Count - filtered.Count,
 0164                    candidates.Count);
 165            }
 166
 0167            return filtered;
 168        }
 0169    }
 170
 171    /// <inheritdoc/>
 172    public async Task<QueryResult<SearchHintInfo>> GetSearchHintsAsync(SearchQuery query, CancellationToken cancellation
 173    {
 0174        ArgumentNullException.ThrowIfNull(query);
 0175        ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
 176
 0177        var providerQuery = BuildProviderQuery(query);
 0178        var candidates = await GetSearchResultsAsync(providerQuery, cancellationToken).ConfigureAwait(false);
 0179        if (candidates.Count == 0)
 180        {
 0181            return new QueryResult<SearchHintInfo>();
 182        }
 183
 0184        var candidateScores = BuildScoreLookup(candidates);
 0185        var user = query.UserId.IsEmpty() ? null : _userManager.GetUserById(query.UserId);
 186
 0187        var excludeItemTypes = BuildExcludeItemTypes(query);
 0188        var includeItemTypes = BuildIncludeItemTypes(query);
 189
 0190        var internalQuery = new InternalItemsQuery(user)
 0191        {
 0192            ItemIds = candidateScores.Keys.ToArray(),
 0193            ExcludeItemTypes = excludeItemTypes.ToArray(),
 0194            IncludeItemTypes = includeItemTypes.Count > 0 ? includeItemTypes.ToArray() : [],
 0195            MediaTypes = query.MediaTypes.ToArray(),
 0196            IncludeItemsByName = !query.ParentId.HasValue,
 0197            ParentId = query.ParentId ?? Guid.Empty,
 0198            Recursive = true,
 0199            IsKids = query.IsKids,
 0200            IsMovie = query.IsMovie,
 0201            IsNews = query.IsNews,
 0202            IsSeries = query.IsSeries,
 0203            IsSports = query.IsSports,
 0204            DtoOptions = new DtoOptions
 0205            {
 0206                Fields =
 0207                [
 0208                    ItemFields.AirTime,
 0209                    ItemFields.DateCreated,
 0210                    ItemFields.ChannelInfo,
 0211                    ItemFields.ParentId
 0212                ]
 0213            }
 0214        };
 215
 216        // MusicArtist items are "ItemsByName" entities - virtual items that aggregate content by artist name
 217        // rather than being stored as regular library items. They require special handling:
 218        // 1. Convert ParentId to AncestorIds (to filter by library folder)
 219        // 2. Set IncludeItemsByName = true (to include these virtual items in results)
 220        // 3. Clear IncludeItemTypes (GetAllArtists handles type filtering internally)
 221        // 4. Use GetAllArtists() instead of GetItemList() to query the artist index
 222        IReadOnlyList<BaseItem> items;
 0223        if (internalQuery.IncludeItemTypes.Length == 1 && internalQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist)
 224        {
 0225            if (!internalQuery.ParentId.IsEmpty())
 226            {
 0227                internalQuery.AncestorIds = [internalQuery.ParentId];
 0228                internalQuery.ParentId = Guid.Empty;
 229            }
 230
 0231            internalQuery.IncludeItemsByName = true;
 0232            internalQuery.IncludeItemTypes = [];
 0233            items = _libraryManager.GetAllArtists(internalQuery).Items.Select(i => i.Item).ToList();
 234        }
 235        else
 236        {
 0237            items = _libraryManager.GetItemList(internalQuery);
 238        }
 239
 0240        var orderedResults = items
 0241            .Select(item => new SearchHintInfo { Item = item })
 0242            .OrderByDescending(hint => candidateScores.GetValueOrDefault(hint.Item.Id, 0f))
 0243            .ToList();
 244
 0245        var totalCount = orderedResults.Count;
 246
 0247        if (query.StartIndex.HasValue)
 248        {
 0249            orderedResults = orderedResults.Skip(query.StartIndex.Value).ToList();
 250        }
 251
 0252        if (query.Limit.HasValue)
 253        {
 0254            orderedResults = orderedResults.Take(query.Limit.Value).ToList();
 255        }
 256
 0257        return new QueryResult<SearchHintInfo>(query.StartIndex, totalCount, orderedResults);
 0258    }
 259
 260    private async Task<IReadOnlyList<SearchResult>> CollectFromProvidersAsync(
 261        IEnumerable<ISearchProvider> providers,
 262        SearchProviderQuery providerQuery,
 263        string searchTerm,
 264        CancellationToken cancellationToken)
 265    {
 0266        var requestedLimit = providerQuery.Limit ?? 100;
 0267        var applicable = providers.Where(p => p.CanSearch(providerQuery)).ToArray();
 0268        if (applicable.Length == 0)
 269        {
 0270            return [];
 271        }
 272
 0273        var perProvider = await Task.WhenAll(
 0274            applicable.Select(p => CollectFromProviderAsync(p, providerQuery, searchTerm, requestedLimit, cancellationTo
 0275            .ConfigureAwait(false);
 276
 0277        var bestScores = new Dictionary<Guid, float>();
 0278        foreach (var providerResults in perProvider)
 279        {
 0280            foreach (var result in providerResults)
 281            {
 0282                UpdateBestScore(bestScores, result);
 283            }
 284        }
 285
 0286        return bestScores
 0287            .Select(kvp => new SearchResult(kvp.Key, kvp.Value))
 0288            .OrderByDescending(r => r.Score)
 0289            .Take(requestedLimit)
 0290            .ToList();
 0291    }
 292
 293    private async Task<IReadOnlyList<SearchResult>> CollectFromProviderAsync(
 294        ISearchProvider provider,
 295        SearchProviderQuery providerQuery,
 296        string searchTerm,
 297        int requestedLimit,
 298        CancellationToken cancellationToken)
 299    {
 300        try
 301        {
 0302            var results = provider is IExternalSearchProvider externalProvider
 0303                ? await CollectFromExternalProviderAsync(externalProvider, providerQuery, requestedLimit, cancellationTo
 0304                : await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false);
 305
 0306            _logger.LogDebug(
 0307                "Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'",
 0308                provider.Name,
 0309                results.Count,
 0310                searchTerm);
 0311            return results;
 312        }
 0313        catch (Exception ex)
 314        {
 0315            _logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTer
 0316            return [];
 317        }
 0318    }
 319
 320    private static async Task<IReadOnlyList<SearchResult>> CollectFromExternalProviderAsync(
 321        IExternalSearchProvider provider,
 322        SearchProviderQuery providerQuery,
 323        int requestedLimit,
 324        CancellationToken cancellationToken)
 325    {
 0326        var results = new List<SearchResult>();
 0327        await foreach (var result in provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false))
 328        {
 0329            results.Add(result);
 0330            if (results.Count >= requestedLimit)
 331            {
 332                break;
 333            }
 334        }
 335
 0336        return results;
 0337    }
 338
 339    private static void UpdateBestScore(Dictionary<Guid, float> bestScores, SearchResult result)
 340    {
 0341        if (!bestScores.TryGetValue(result.ItemId, out var existingScore) || result.Score > existingScore)
 342        {
 0343            bestScores[result.ItemId] = result.Score;
 344        }
 0345    }
 346
 347    private static Dictionary<Guid, float> BuildScoreLookup(IReadOnlyList<SearchResult> results)
 348    {
 0349        var lookup = new Dictionary<Guid, float>(results.Count);
 0350        foreach (var result in results)
 351        {
 0352            lookup[result.ItemId] = result.Score;
 353        }
 354
 0355        return lookup;
 356    }
 357
 358    private static SearchProviderQuery BuildProviderQuery(SearchQuery query)
 359    {
 0360        var excludeItemTypes = BuildExcludeItemTypes(query);
 0361        var includeItemTypes = BuildIncludeItemTypes(query);
 362
 363        // Remove any excluded types from includes
 0364        if (includeItemTypes.Count > 0 && excludeItemTypes.Count > 0)
 365        {
 0366            includeItemTypes.RemoveAll(excludeItemTypes.Contains);
 367        }
 368
 0369        return new SearchProviderQuery
 0370        {
 0371            SearchTerm = query.SearchTerm,
 0372            UserId = query.UserId.IsEmpty() ? null : query.UserId,
 0373            IncludeItemTypes = includeItemTypes.ToArray(),
 0374            ExcludeItemTypes = excludeItemTypes.ToArray(),
 0375            MediaTypes = query.MediaTypes.ToArray(),
 0376            Limit = query.Limit,
 0377            ParentId = query.ParentId
 0378        };
 379    }
 380
 381    private static List<BaseItemKind> BuildExcludeItemTypes(SearchQuery query)
 382    {
 0383        var excludeItemTypes = query.ExcludeItemTypes.ToList();
 384
 0385        excludeItemTypes.Add(BaseItemKind.Year);
 0386        excludeItemTypes.Add(BaseItemKind.Folder);
 0387        excludeItemTypes.Add(BaseItemKind.CollectionFolder);
 388
 0389        if (!query.IncludeGenres)
 390        {
 0391            AddIfMissing(excludeItemTypes, BaseItemKind.Genre);
 0392            AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre);
 393        }
 394
 0395        if (!query.IncludePeople)
 396        {
 0397            AddIfMissing(excludeItemTypes, BaseItemKind.Person);
 398        }
 399
 0400        if (!query.IncludeStudios)
 401        {
 0402            AddIfMissing(excludeItemTypes, BaseItemKind.Studio);
 403        }
 404
 0405        if (!query.IncludeArtists)
 406        {
 0407            AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist);
 408        }
 409
 0410        return excludeItemTypes;
 411    }
 412
 413    private static List<BaseItemKind> BuildIncludeItemTypes(SearchQuery query)
 414    {
 0415        var includeItemTypes = query.IncludeItemTypes.ToList();
 0416        if (query.IncludeMedia)
 417        {
 0418            return includeItemTypes;
 419        }
 420
 0421        if (query.IncludeGenres && IsEmptyOrContains(includeItemTypes, BaseItemKind.Genre))
 422        {
 0423            AddIfMissing(includeItemTypes, BaseItemKind.Genre);
 0424            AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre);
 425        }
 426
 0427        if (query.IncludePeople && IsEmptyOrContains(includeItemTypes, BaseItemKind.Person))
 428        {
 0429            AddIfMissing(includeItemTypes, BaseItemKind.Person);
 430        }
 431
 0432        if (query.IncludeStudios && IsEmptyOrContains(includeItemTypes, BaseItemKind.Studio))
 433        {
 0434            AddIfMissing(includeItemTypes, BaseItemKind.Studio);
 435        }
 436
 0437        if (query.IncludeArtists && IsEmptyOrContains(includeItemTypes, BaseItemKind.MusicArtist))
 438        {
 0439            AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist);
 440        }
 441
 0442        return includeItemTypes;
 443    }
 444
 445    private static bool IsEmptyOrContains(List<BaseItemKind> list, BaseItemKind value)
 0446        => list.Count == 0 || list.Contains(value);
 447
 448    private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value)
 449    {
 0450        if (!list.Contains(value))
 451        {
 0452            list.Add(value);
 453        }
 0454    }
 455}