using System.Diagnostics;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Nuuru.Server.Auth;
using Nuuru.Server.Data;
using Nuuru.Server.DTOs;
using Nuuru.Server.DTOs.Booru;
using Nuuru.Server.Extensions;
namespace Nuuru.Server.Services.Search;
///
/// Implementation of the search service with full booru-style query support.
///
public class BooruSearchService : IBooruSearchService
{
private static readonly TimeSpan ParseCacheDuration = TimeSpan.FromMinutes(10);
private static readonly TimeSpan CountCacheDuration = TimeSpan.FromMinutes(2);
private readonly ApplicationDbContext _context;
private readonly ICurrentUserContext _userContext;
private readonly IUserBadgeService _userBadgeService;
private readonly IUserSettingsService _userSettingsService;
private readonly ISiteSettingsService _siteSettings;
private readonly IMemoryCache _cache;
private readonly ILogger _logger;
public BooruSearchService(
ApplicationDbContext context,
ICurrentUserContext userContext,
IUserBadgeService userBadgeService,
IUserSettingsService userSettingsService,
ISiteSettingsService siteSettings,
IMemoryCache cache,
ILogger logger)
{
_context = context;
_userContext = userContext;
_userBadgeService = userBadgeService;
_userSettingsService = userSettingsService;
_siteSettings = siteSettings;
_cache = cache;
_logger = logger;
}
public async Task SearchPostsAsync(string? query, int page = 1, int pageSize = 20)
{
var sw = Stopwatch.StartNew();
var warnings = new List();
try
{
// Prepend default search query from user settings
string? defaultQuery = null;
if (_userContext.UserId.HasValue)
{
defaultQuery = await _userSettingsService.GetDefaultSearchQueryAsync(_userContext.UserId.Value);
}
else
{
// For logged out users, filter explicit content by default
defaultQuery = SearchDefaults.DefaultQuery;
}
var effectiveQuery = string.IsNullOrWhiteSpace(defaultQuery)
? query
: string.IsNullOrWhiteSpace(query)
? defaultQuery
: $"{defaultQuery} {query}";
// Handle empty/null query - return all posts
if (string.IsNullOrWhiteSpace(effectiveQuery))
{
return await GetDefaultResultsAsync(page, pageSize, sw);
}
// Parse with caching
var parseResult = GetOrParseQuery(effectiveQuery);
warnings.AddRange(parseResult.Warnings);
if (parseResult.Errors.Count > 0)
{
warnings.AddRange(parseResult.Errors);
return CreateEmptyResult(effectiveQuery, warnings, sw);
}
// Pre-resolve all tag IDs async in a single batch — subsequent sync resolvers
// during query building will be instant cache hits with no blocking DB I/O
var builder = new SearchQueryBuilder(_context, _userContext, _cache);
await builder.PreResolveTagIdsAsync(parseResult);
var dbQuery = builder.BuildQuery(parseResult);
// Cache count per effective query + user to avoid duplicate COUNT scans
// across concurrent requests. UserId determines permissions, so no need to key on both.
var countCacheKey = $"search_count:{_userContext.UserId}:{effectiveQuery}";
if (!_cache.TryGetValue(countCacheKey, out int totalCount))
{
var countQuery = builder.BuildCountQuery(parseResult);
totalCount = await countQuery.CountAsync();
_cache.Set(countCacheKey, totalCount, CountCacheDuration);
}
var posts = await dbQuery
.Skip((page - 1) * pageSize)
.Take(pageSize)
.AsSplitQuery()
.ToListAsync();
var bointsEnabled = await _siteSettings.GetBoolAsync("boints.enabled", false);
// On page 1, prepend boosted posts that match the query filters
if (page == 1 && bointsEnabled)
{
var now = DateTime.UtcNow;
var boostedQuery = builder.BuildQuery(parseResult)
.Where(p => p.BoostedUntil != null && p.BoostedUntil > now);
var boostedPosts = await boostedQuery
.OrderByDescending(p => p.BoostedUntil)
.Take(5)
.AsSplitQuery()
.ToListAsync();
if (boostedPosts.Count > 0)
{
var boostedIds = boostedPosts.Select(p => p.Id).ToHashSet();
posts = boostedPosts
.Concat(posts.Where(p => !boostedIds.Contains(p.Id)))
.ToList();
}
}
// Batch-fetch display info for all uploaders and approvers
var userIds = posts
.SelectMany(p => new[] { p.Uploader?.Id, p.ApprovedBy?.Id })
.Where(id => id.HasValue)
.Select(id => id!.Value)
.Distinct();
var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(userIds);
sw.Stop();
return new SearchResult
{
Posts = new PagedResult
{
Items = posts.ToDto(displayInfoMap),
TotalCount = totalCount,
Page = page,
PageSize = pageSize
},
Metadata = new SearchMetadata
{
ParsedQuery = query,
Warnings = warnings,
QueryTimeMs = sw.Elapsed.TotalMilliseconds
}
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing search query: {Query}", query);
sw.Stop();
return new SearchResult
{
Posts = new PagedResult
{
Items = [],
TotalCount = 0,
Page = page,
PageSize = pageSize
},
Metadata = new SearchMetadata
{
ParsedQuery = query ?? string.Empty,
Warnings = ["An error occurred while executing the search"],
QueryTimeMs = sw.Elapsed.TotalMilliseconds
}
};
}
}
public async Task> BuildQueryAsync(string? query)
{
var effectiveQuery = await GetEffectiveQueryAsync(query);
if (string.IsNullOrWhiteSpace(effectiveQuery))
{
// Default query: exclude trashed and handle approval status
var canSeeUnapproved = _userContext.HasPermission(Permissions.Moderation.ApprovePost);
var currentUserId = _userContext.UserId;
var q = _context.BooruPosts
.Where(p => !p.IsTrashed)
.Include(p => p.Uploader)
.Include(p => p.PostTags)
.ThenInclude(pt => pt.Tag)
.ThenInclude(t => t.Category)
.AsQueryable();
if (!canSeeUnapproved)
{
if (currentUserId.HasValue)
{
q = q.Where(p => p.IsApproved || p.UploaderId == currentUserId.Value);
}
else
{
q = q.Where(p => p.IsApproved);
}
}
return q.OrderByDescending(p => p.UploadedAt);
}
var parseResult = GetOrParseQuery(effectiveQuery);
if (parseResult.Errors.Count > 0)
{
// Fallback to empty query on error
return _context.BooruPosts.Where(p => false);
}
var builder = new SearchQueryBuilder(_context, _userContext, _cache);
await builder.PreResolveTagIdsAsync(parseResult);
return builder.BuildQuery(parseResult);
}
private async Task GetEffectiveQueryAsync(string? query)
{
// Prepend default search query from user settings
string? defaultQuery = null;
if (_userContext.UserId.HasValue)
{
defaultQuery = await _userSettingsService.GetDefaultSearchQueryAsync(_userContext.UserId.Value);
}
else
{
// For logged out users, filter explicit content by default
defaultQuery = SearchDefaults.DefaultQuery;
}
return string.IsNullOrWhiteSpace(defaultQuery)
? query ?? ""
: string.IsNullOrWhiteSpace(query)
? defaultQuery
: $"{defaultQuery} {query}";
}
public SearchValidationResult ValidateQuery(string query)
{
var errors = new List();
var warnings = new List();
if (string.IsNullOrWhiteSpace(query))
{
return new SearchValidationResult { IsValid = true };
}
try
{
var tokenizer = new SearchTokenizer(query);
var tokens = tokenizer.Tokenize();
// Check for unbalanced braces
var braceDepth = 0;
foreach (var token in tokens)
{
if (token is Tokens.OrGroupStartToken) braceDepth++;
if (token is Tokens.OrGroupEndToken) braceDepth--;
if (braceDepth < 0)
{
errors.Add("Unmatched closing brace '}'");
break;
}
}
if (braceDepth > 0)
{
errors.Add("Unclosed brace '{'");
}
// Parse and collect warnings
var parser = new SearchParser(tokens);
var parseResult = parser.Parse();
errors.AddRange(parseResult.Errors);
warnings.AddRange(parseResult.Warnings);
}
catch (Exception ex)
{
errors.Add($"Parse error: {ex.Message}");
}
return new SearchValidationResult
{
IsValid = errors.Count == 0,
Errors = errors,
Warnings = warnings
};
}
private async Task GetDefaultResultsAsync(int page, int pageSize, Stopwatch sw)
{
// Build filter predicate based on approval status
var canSeeUnapproved = _userContext.HasPermission(Permissions.Moderation.ApprovePost);
var currentUserId = _userContext.UserId;
// Cache the default count — no tag filters, just approval/trash checks
var countCacheKey = $"search_count_default:{currentUserId}";
if (!_cache.TryGetValue(countCacheKey, out int totalCount))
{
var countQuery = _context.BooruPosts.Where(p => !p.IsTrashed);
if (!canSeeUnapproved)
{
if (currentUserId.HasValue)
{
countQuery = countQuery.Where(p => p.IsApproved || p.UploaderId == currentUserId.Value);
}
else
{
countQuery = countQuery.Where(p => p.IsApproved);
}
}
totalCount = await countQuery.CountAsync();
_cache.Set(countCacheKey, totalCount, CountCacheDuration);
}
// Data query with includes - exclude trashed
var dataQuery = _context.BooruPosts
.Where(p => !p.IsTrashed)
.Include(p => p.Uploader)
.Include(p => p.PostTags)
.ThenInclude(pt => pt.Tag)
.ThenInclude(t => t.Category)
.AsQueryable();
if (!canSeeUnapproved)
{
if (currentUserId.HasValue)
{
dataQuery = dataQuery.Where(p => p.IsApproved || p.UploaderId == currentUserId.Value);
}
else
{
dataQuery = dataQuery.Where(p => p.IsApproved);
}
}
var posts = await dataQuery
.OrderByDescending(p => p.UploadedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.AsSplitQuery()
.ToListAsync();
var bointsEnabled = await _siteSettings.GetBoolAsync("boints.enabled", false);
// On page 1, prepend boosted posts
if (page == 1 && bointsEnabled)
{
var now = DateTime.UtcNow;
var boostedPosts = await dataQuery
.Where(p => p.BoostedUntil != null && p.BoostedUntil > now)
.OrderByDescending(p => p.BoostedUntil)
.Take(5)
.AsSplitQuery()
.ToListAsync();
if (boostedPosts.Count > 0)
{
var boostedIds = boostedPosts.Select(p => p.Id).ToHashSet();
posts = boostedPosts
.Concat(posts.Where(p => !boostedIds.Contains(p.Id)))
.ToList();
}
}
// Batch-fetch display info for all uploaders
var uploaderIds = posts
.Where(p => p.Uploader != null)
.Select(p => p.Uploader!.Id)
.Distinct();
var displayInfoMap = await _userBadgeService.GetUsersDisplayInfoAsync(uploaderIds);
sw.Stop();
return new SearchResult
{
Posts = new PagedResult
{
Items = posts.ToDto(displayInfoMap),
TotalCount = totalCount,
Page = page,
PageSize = pageSize
},
Metadata = new SearchMetadata
{
ParsedQuery = string.Empty,
Warnings = [],
QueryTimeMs = sw.Elapsed.TotalMilliseconds
}
};
}
private SearchResult CreateEmptyResult(string query, List warnings, Stopwatch sw)
{
sw.Stop();
return new SearchResult
{
Posts = new PagedResult
{
Items = [],
TotalCount = 0,
Page = 1,
PageSize = 20
},
Metadata = new SearchMetadata
{
ParsedQuery = query,
Warnings = warnings,
QueryTimeMs = sw.Elapsed.TotalMilliseconds
}
};
}
///
/// Returns a cached parse result for the given query string, or tokenizes+parses and caches it.
///
private SearchParseResult GetOrParseQuery(string query)
{
var cacheKey = $"search_parse:{query}";
if (_cache.TryGetValue(cacheKey, out SearchParseResult? cached))
return cached!;
var tokenizer = new SearchTokenizer(query);
var tokens = tokenizer.Tokenize();
var parser = new SearchParser(tokens);
var parseResult = parser.Parse();
if (parseResult.Errors.Count == 0)
{
_cache.Set(cacheKey, parseResult, ParseCacheDuration);
}
return parseResult;
}
}