using ErsatzTV.Core.Api.Search; using ErsatzTV.Core.Domain; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Application.Search.Queries; public class GetSearchFieldValuesHandler(IDbContextFactory dbContextFactory) : IRequestHandler> { private const int DefaultLimit = 50; private const int MaxLimit = 50; public async Task> Handle( GetSearchFieldValues request, CancellationToken cancellationToken) { SearchFieldResponseModel field = SearchFieldCatalog.Fields .FirstOrDefault(f => f.Name == request.Name); if (field is null || field.Type != "text") { return Option.None; } int limit = request.Limit <= 0 ? DefaultLimit : Math.Clamp(request.Limit, 1, MaxLimit); string qLower = (request.Query ?? string.Empty).ToLower(); // in-memory special cases (no DB query needed) switch (request.Name) { case "state": return new SearchFieldValuesResponseModel( FilterSortTake(Enum.GetNames(), qLower, limit)); case "video_dynamic_range": return new SearchFieldValuesResponseModel( FilterSortTake(["hdr", "sdr"], qLower, limit)); } await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); if (request.Name == "content_rating") { return new SearchFieldValuesResponseModel( await GetContentRatingValues(dbContext, qLower, limit, cancellationToken)); } IQueryable source = GetSource(dbContext, request.Name); if (source is null) { return Option.None; } List values = await source .Where(v => v != null && v.ToLower().StartsWith(qLower)) .Distinct() .OrderBy(v => v) .Take(limit) .ToListAsync(cancellationToken); return new SearchFieldValuesResponseModel(values); } private static IQueryable GetSource(TvContext dbContext, string name) => name switch { "genre" or "show_genre" => dbContext.Set().Select(g => g.Name), "studio" => dbContext.Set().Select(s => s.Name), "director" => dbContext.Set().Select(d => d.Name), "writer" => dbContext.Set().Select(w => w.Name), "actor" => dbContext.Actors.Select(a => a.Name), // entity artists only; free-text music-video/song artist credits are not included (known limitation) "artist" => dbContext.ArtistMetadata.Select(m => m.Title), "tag" => dbContext.Set() .Where(t => t.ExternalTypeId != Tag.NfoCountryTypeId && t.ExternalTypeId != Tag.PlexNetworkTypeId) .Select(t => t.Name), "network" => dbContext.Set() .Where(t => t.ExternalTypeId == Tag.PlexNetworkTypeId) .Select(t => t.Name), "collection" => dbContext.Collections.Select(c => c.Name), "video_codec" => dbContext.MediaStreams .Where(s => s.MediaStreamKind == MediaStreamKind.Video && s.Codec != null) .Select(s => s.Codec), "album" => dbContext.MusicVideoMetadata .Where(m => m.Album != null) .Select(m => m.Album) .Concat(dbContext.SongMetadata.Where(m => m.Album != null).Select(m => m.Album)), _ => null }; private static async Task> GetContentRatingValues( TvContext dbContext, string qLower, int limit, CancellationToken cancellationToken) { List raw = await dbContext.MovieMetadata .Where(m => m.ContentRating != null) .Select(m => m.ContentRating) .Concat(dbContext.ShowMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating)) .Concat(dbContext.OtherVideoMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating)) .Concat(dbContext.RemoteStreamMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating)) .Distinct() .ToListAsync(cancellationToken); IEnumerable split = raw .SelectMany(cr => cr.Split('/')) .Select(cr => cr.Trim()) .Where(cr => !string.IsNullOrEmpty(cr)) .Distinct(); return FilterSortTake(split, qLower, limit); } private static List FilterSortTake(IEnumerable values, string qLower, int limit) => values .Where(v => v.ToLower().StartsWith(qLower)) .OrderBy(v => v) .Take(limit) .ToList(); }