aboutsummaryrefslogtreecommitdiff
path: root/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
blob: bc766f1c8c256449828474de5c8992d7b69f14b0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
#pragma warning disable RS0030 // Do not use banned APIs
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons

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.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Configuration;
using Microsoft.EntityFrameworkCore;

namespace Emby.Server.Implementations.Library.Search;

/// <summary>
/// Built-in SQL-based search provider that queries the library database directly.
/// </summary>
public class SqlSearchProvider : IInternalSearchProvider
{
    private const int DefaultSearchLimit = 100;
    private const float ExactMatchScore = 100f;
    private const float PrefixMatchScore = 80f;
    private const float WordPrefixMatchScore = 75f;
    private const float ContainsMatchScore = 50f;

    private static readonly Guid _placeholderId = Guid.Parse("00000000-0000-0000-0000-000000000001");

    private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
    private readonly IItemTypeLookup _itemTypeLookup;
    private readonly ILibraryManager _libraryManager;
    private readonly IUserManager _userManager;
    private readonly IItemQueryHelpers _queryHelpers;

    /// <summary>
    /// Initializes a new instance of the <see cref="SqlSearchProvider"/> class.
    /// </summary>
    /// <param name="dbProvider">The database context factory.</param>
    /// <param name="itemTypeLookup">The item type lookup.</param>
    /// <param name="libraryManager">The library manager.</param>
    /// <param name="userManager">The user manager.</param>
    /// <param name="queryHelpers">The shared item query helpers.</param>
    public SqlSearchProvider(
        IDbContextFactory<JellyfinDbContext> dbProvider,
        IItemTypeLookup itemTypeLookup,
        ILibraryManager libraryManager,
        IUserManager userManager,
        IItemQueryHelpers queryHelpers)
    {
        _dbProvider = dbProvider;
        _itemTypeLookup = itemTypeLookup;
        _libraryManager = libraryManager;
        _userManager = userManager;
        _queryHelpers = queryHelpers;
    }

    /// <inheritdoc/>
    public string Name => "Database";

    /// <inheritdoc/>
    public MetadataPluginType Type => MetadataPluginType.SearchProvider;

    /// <inheritdoc/>
    public int Priority => 100; // Low priority - runs as fallback

    /// <inheritdoc/>
    public bool CanSearch(SearchProviderQuery query)
    {
        // SQL search can always handle any query
        return true;
    }

    /// <inheritdoc/>
    public async Task<IReadOnlyList<SearchResult>> SearchAsync(SearchProviderQuery query, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(query);
        ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);

        var rawSearchTerm = query.SearchTerm.Trim().RemoveDiacritics();
        if (string.IsNullOrEmpty(rawSearchTerm))
        {
            return [];
        }

        var cleanSearchTerm = rawSearchTerm.GetCleanValue();
        if (string.IsNullOrEmpty(cleanSearchTerm))
        {
            return [];
        }

        var cleanPrefix = cleanSearchTerm + " ";
        // OriginalTitle is stored mixed-case and isn't pre-normalized like CleanName,
        // so match it via a case-insensitive LIKE rather than a per-row case conversion
        // that may not translate to SQL on every provider.
        var likeOriginal = $"%{rawSearchTerm}%";
        var limit = query.Limit ?? DefaultSearchLimit;

        var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
        await using (dbContext.ConfigureAwait(false))
        {
            // Lightweight projection: select only what's needed to score and identify items.
            var dbQuery = dbContext.BaseItems
                .AsNoTracking()
                .Where(e => e.Id != _placeholderId)
                .Where(e => !e.IsVirtualItem)
                .Where(e => e.CleanName!.Contains(cleanSearchTerm)
                    || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal)));

            dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes);
            dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes);
            dbQuery = ApplyParentFilter(dbQuery, query.ParentId);
            dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query.UserId);

            // Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is
            // the pre-normalized (lowercase, diacritic-stripped) form, so we score against it
            // directly without any per-row case conversion. Items that match only via
            // OriginalTitle fall through to the Contains tier.
            // Tie-break by Id for deterministic ordering so the explicit OrderBy + Take
            // satisfies EF Core's row-limiting-with-OrderBy requirement.
            var scored = dbQuery.Select(e => new
            {
                e.Id,
                Score =
                    (e.CleanName == cleanSearchTerm) ? ExactMatchScore
                    : e.CleanName!.StartsWith(cleanSearchTerm) ? PrefixMatchScore
                    : e.CleanName!.Contains(cleanPrefix) ? WordPrefixMatchScore
                    : ContainsMatchScore
            });

            return await scored
                .OrderByDescending(x => x.Score)
                .ThenBy(x => x.Id)
                .Take(limit)
                .Select(x => new SearchResult(x.Id, x.Score))
                .ToArrayAsync(cancellationToken)
                .ConfigureAwait(false);
        }
    }

    private IQueryable<BaseItemEntity> ApplyTypeFilter(
        IQueryable<BaseItemEntity> query,
        BaseItemKind[] includeItemTypes,
        BaseItemKind[] excludeItemTypes)
    {
        if (includeItemTypes.Length > 0)
        {
            var includeTypeNames = MapKindsToTypeNames(includeItemTypes);
            if (includeTypeNames.Count > 0)
            {
                query = query.Where(e => includeTypeNames.Contains(e.Type));
            }
        }
        else if (excludeItemTypes.Length > 0)
        {
            var excludeTypeNames = MapKindsToTypeNames(excludeItemTypes);
            if (excludeTypeNames.Count > 0)
            {
                query = query.Where(e => !excludeTypeNames.Contains(e.Type));
            }
        }

        return query;
    }

    private static IQueryable<BaseItemEntity> ApplyMediaTypeFilter(
        IQueryable<BaseItemEntity> query,
        MediaType[] mediaTypes)
    {
        if (mediaTypes.Length == 0)
        {
            return query;
        }

        var mediaTypeNames = mediaTypes.Select(m => m.ToString()).ToArray();
        return query.Where(e => e.MediaType != null && mediaTypeNames.Contains(e.MediaType));
    }

    private static IQueryable<BaseItemEntity> ApplyParentFilter(
        IQueryable<BaseItemEntity> query,
        Guid? parentId)
    {
        if (!parentId.HasValue || parentId.Value.IsEmpty())
        {
            return query;
        }

        var pid = parentId.Value;
        return query.Where(e => e.ParentId == pid || e.Parents!.Any(p => p.ParentItemId == pid));
    }

    private IQueryable<BaseItemEntity> ApplyUserAccessFilter(
        JellyfinDbContext dbContext,
        IQueryable<BaseItemEntity> query,
        Guid? userId)
    {
        if (!userId.HasValue || userId.Value.IsEmpty())
        {
            return query;
        }

        var user = _userManager.GetUserById(userId.Value);
        if (user is null)
        {
            return query;
        }

        var accessFilter = new InternalItemsQuery(user);
        _libraryManager.ConfigureUserAccess(accessFilter, user);
        return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter);
    }

    private List<string> MapKindsToTypeNames(BaseItemKind[] kinds)
    {
        var list = new List<string>(kinds.Length);
        foreach (var kind in kinds)
        {
            if (_itemTypeLookup.BaseItemKindNames.TryGetValue(kind, out var name) && name is not null)
            {
                list.Add(name);
            }
        }

        return list;
    }
}