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.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Querying; using MediaBrowser.Model.Search; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.Library.Search; /// /// Manages search providers and orchestrates search operations. /// public class SearchManager : ISearchManager { private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; private readonly IDbContextFactory _dbProvider; private readonly IItemQueryHelpers _queryHelpers; private readonly ILogger _logger; private IExternalSearchProvider[] _externalProviders = []; private IInternalSearchProvider[] _internalProviders = []; /// /// Initializes a new instance of the class. /// /// The library manager. /// The user manager. /// The database context factory. /// The shared item query helpers. /// The logger. public SearchManager( ILibraryManager libraryManager, IUserManager userManager, IDbContextFactory dbProvider, IItemQueryHelpers queryHelpers, ILogger logger) { _libraryManager = libraryManager; _userManager = userManager; _dbProvider = dbProvider; _queryHelpers = queryHelpers; _logger = logger; } /// public void AddParts(IEnumerable providers) { var allProviders = providers.OrderBy(p => p.Priority).ToArray(); _externalProviders = allProviders.OfType().ToArray(); _internalProviders = allProviders.OfType().ToArray(); _logger.LogInformation( "Registered {ExternalCount} external search providers: {ExternalProviders}. Fallback providers: {FallbackProviders}", _externalProviders.Length, string.Join(", ", _externalProviders.Select(p => $"{p.Name} (priority {p.Priority})")), string.Join(", ", _internalProviders.Select(p => $"{p.Name} (priority {p.Priority})"))); } /// public IReadOnlyList GetProviders() { return [.. _externalProviders, .. _internalProviders]; } /// public async Task> GetSearchResultsAsync( SearchProviderQuery query, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(query); ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm); var searchTerm = query.SearchTerm.Trim().RemoveDiacritics(); var results = await CollectFromProvidersAsync(_externalProviders, query, searchTerm, cancellationToken).ConfigureAwait(false); if (results.Count == 0 && _internalProviders.Length > 0) { _logger.LogDebug("No results from external providers, falling back to internal providers"); results = await CollectFromProvidersAsync(_internalProviders, query, searchTerm, cancellationToken).ConfigureAwait(false); } // External providers don't know about user permissions, so they may return IDs from // hidden libraries or items the user is otherwise blocked from. Filter the candidate // set to only items this user can access (top-parent libraries, parental rating, // blocked/allowed tags, owned-item rules) before returning. The Items controller's // second roundtrip via folder.GetItems applies most of these again, but it does not // restrict by TopParentIds when ItemIds is set, leaving a gap that this closes. if (results.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty()) { var user = _userManager.GetUserById(query.UserId.Value); if (user is not null) { results = await FilterByUserAccessAsync(results, user, cancellationToken).ConfigureAwait(false); } } return results; } private async Task> FilterByUserAccessAsync( IReadOnlyList candidates, User user, CancellationToken cancellationToken) { // SetUser populates parental rating + blocked/allowed tags. ConfigureUserAccess populates // TopParentIds for the user's accessible libraries — we call it before assigning ItemIds // because LibraryManager.AddUserToQuery skips TopParentIds when ItemIds is non-empty. var accessFilter = new InternalItemsQuery(user); _libraryManager.ConfigureUserAccess(accessFilter, user); var candidateIds = new Guid[candidates.Count]; for (var i = 0; i < candidates.Count; i++) { candidateIds[i] = candidates[i].ItemId; } var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { var baseQuery = dbContext.BaseItems .AsNoTracking() .Where(e => candidateIds.Contains(e.Id)); baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter); var allowedIds = await baseQuery .Select(e => e.Id) .ToHashSetAsync(cancellationToken) .ConfigureAwait(false); if (allowedIds.Count == candidates.Count) { return candidates; } var filtered = new List(allowedIds.Count); foreach (var c in candidates) { if (allowedIds.Contains(c.ItemId)) { filtered.Add(c); } } if (filtered.Count < candidates.Count) { _logger.LogDebug( "Dropped {Dropped} of {Total} search candidates due to user access filtering", candidates.Count - filtered.Count, candidates.Count); } return filtered; } } /// public async Task> GetSearchHintsAsync(SearchQuery query, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(query); ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm); var providerQuery = BuildProviderQuery(query); var candidates = await GetSearchResultsAsync(providerQuery, cancellationToken).ConfigureAwait(false); if (candidates.Count == 0) { return new QueryResult(); } var candidateScores = BuildScoreLookup(candidates); var user = !query.UserId.IsEmpty() ? _userManager.GetUserById(query.UserId) : null; var excludeItemTypes = BuildExcludeItemTypes(query); var includeItemTypes = BuildIncludeItemTypes(query); var internalQuery = new InternalItemsQuery(user) { ItemIds = candidateScores.Keys.ToArray(), ExcludeItemTypes = excludeItemTypes.ToArray(), IncludeItemTypes = includeItemTypes.Count > 0 ? includeItemTypes.ToArray() : [], MediaTypes = query.MediaTypes.ToArray(), IncludeItemsByName = !query.ParentId.HasValue, ParentId = query.ParentId ?? Guid.Empty, Recursive = true, IsKids = query.IsKids, IsMovie = query.IsMovie, IsNews = query.IsNews, IsSeries = query.IsSeries, IsSports = query.IsSports, DtoOptions = new DtoOptions { Fields = [ ItemFields.AirTime, ItemFields.DateCreated, ItemFields.ChannelInfo, ItemFields.ParentId ] } }; // MusicArtist items are "ItemsByName" entities - virtual items that aggregate content by artist name // rather than being stored as regular library items. They require special handling: // 1. Convert ParentId to AncestorIds (to filter by library folder) // 2. Set IncludeItemsByName = true (to include these virtual items in results) // 3. Clear IncludeItemTypes (GetAllArtists handles type filtering internally) // 4. Use GetAllArtists() instead of GetItemList() to query the artist index IReadOnlyList items; if (internalQuery.IncludeItemTypes.Length == 1 && internalQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist) { if (!internalQuery.ParentId.IsEmpty()) { internalQuery.AncestorIds = [internalQuery.ParentId]; internalQuery.ParentId = Guid.Empty; } internalQuery.IncludeItemsByName = true; internalQuery.IncludeItemTypes = []; items = _libraryManager.GetAllArtists(internalQuery).Items.Select(i => i.Item).ToList(); } else { items = _libraryManager.GetItemList(internalQuery); } var orderedResults = items .Select(item => new SearchHintInfo { Item = item }) .OrderByDescending(hint => candidateScores.GetValueOrDefault(hint.Item.Id, 0f)) .ToList(); var totalCount = orderedResults.Count; if (query.StartIndex.HasValue) { orderedResults = orderedResults.Skip(query.StartIndex.Value).ToList(); } if (query.Limit.HasValue) { orderedResults = orderedResults.Take(query.Limit.Value).ToList(); } return new QueryResult(query.StartIndex, totalCount, orderedResults); } private async Task> CollectFromProvidersAsync( IEnumerable providers, SearchProviderQuery providerQuery, string searchTerm, CancellationToken cancellationToken) { var bestScores = new Dictionary(); var requestedLimit = providerQuery.Limit ?? 100; foreach (var provider in providers.Where(p => p.CanSearch(providerQuery))) { if (bestScores.Count >= requestedLimit || cancellationToken.IsCancellationRequested) { break; } try { if (provider is IExternalSearchProvider externalProvider) { var count = 0; await foreach (var result in externalProvider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false)) { UpdateBestScore(bestScores, result); count++; if (bestScores.Count >= requestedLimit) { break; } } _logger.LogDebug( "External provider {Provider} returned {Count} candidates for search term '{SearchTerm}'", provider.Name, count, searchTerm); } else { var candidates = await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false); foreach (var result in candidates) { UpdateBestScore(bestScores, result); } _logger.LogDebug( "Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'", provider.Name, candidates.Count, searchTerm); } } catch (Exception ex) { _logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTerm); } } return bestScores .Select(kvp => new SearchResult(kvp.Key, kvp.Value)) .OrderByDescending(r => r.Score) .Take(requestedLimit) .ToList(); } private static void UpdateBestScore(Dictionary bestScores, SearchResult result) { if (!bestScores.TryGetValue(result.ItemId, out var existingScore) || result.Score > existingScore) { bestScores[result.ItemId] = result.Score; } } private static Dictionary BuildScoreLookup(IReadOnlyList results) { var lookup = new Dictionary(results.Count); foreach (var result in results) { lookup[result.ItemId] = result.Score; } return lookup; } private static SearchProviderQuery BuildProviderQuery(SearchQuery query) { var excludeItemTypes = BuildExcludeItemTypes(query); var includeItemTypes = BuildIncludeItemTypes(query); // Remove any excluded types from includes if (includeItemTypes.Count > 0 && excludeItemTypes.Count > 0) { includeItemTypes.RemoveAll(excludeItemTypes.Contains); } return new SearchProviderQuery { SearchTerm = query.SearchTerm, UserId = query.UserId.IsEmpty() ? null : query.UserId, IncludeItemTypes = includeItemTypes.ToArray(), ExcludeItemTypes = excludeItemTypes.ToArray(), MediaTypes = query.MediaTypes.ToArray(), Limit = query.Limit, ParentId = query.ParentId }; } private static List BuildExcludeItemTypes(SearchQuery query) { var excludeItemTypes = query.ExcludeItemTypes.ToList(); excludeItemTypes.Add(BaseItemKind.Year); excludeItemTypes.Add(BaseItemKind.Folder); excludeItemTypes.Add(BaseItemKind.CollectionFolder); if (!query.IncludeGenres) { AddIfMissing(excludeItemTypes, BaseItemKind.Genre); AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre); } if (!query.IncludePeople) { AddIfMissing(excludeItemTypes, BaseItemKind.Person); } if (!query.IncludeStudios) { AddIfMissing(excludeItemTypes, BaseItemKind.Studio); } if (!query.IncludeArtists) { AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist); } return excludeItemTypes; } private static List BuildIncludeItemTypes(SearchQuery query) { var includeItemTypes = query.IncludeItemTypes.ToList(); if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Genre))) { if (!query.IncludeMedia) { AddIfMissing(includeItemTypes, BaseItemKind.Genre); AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre); } } if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Person))) { if (!query.IncludeMedia) { AddIfMissing(includeItemTypes, BaseItemKind.Person); } } if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Studio))) { if (!query.IncludeMedia) { AddIfMissing(includeItemTypes, BaseItemKind.Studio); } } if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.MusicArtist))) { if (!query.IncludeMedia) { AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist); } } return includeItemTypes; } private static void AddIfMissing(List list, BaseItemKind value) { if (!list.Contains(value)) { list.Add(value); } } }