PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m24s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m43s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m38s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m50s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
121 lines
4.8 KiB
C#
121 lines
4.8 KiB
C#
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<TvContext> dbContextFactory)
|
|
: IRequestHandler<GetSearchFieldValues, Option<SearchFieldValuesResponseModel>>
|
|
{
|
|
private const int DefaultLimit = 50;
|
|
private const int MaxLimit = 50;
|
|
|
|
public async Task<Option<SearchFieldValuesResponseModel>> Handle(
|
|
GetSearchFieldValues request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SearchFieldResponseModel field = SearchFieldCatalog.Fields
|
|
.FirstOrDefault(f => f.Name == request.Name);
|
|
|
|
if (field is null || field.Type != "text")
|
|
{
|
|
return Option<SearchFieldValuesResponseModel>.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<MediaItemState>(), 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<string> source = GetSource(dbContext, request.Name);
|
|
if (source is null)
|
|
{
|
|
return Option<SearchFieldValuesResponseModel>.None;
|
|
}
|
|
|
|
List<string> 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<string> GetSource(TvContext dbContext, string name) => name switch
|
|
{
|
|
"genre" or "show_genre" => dbContext.Set<Genre>().Select(g => g.Name),
|
|
"studio" => dbContext.Set<Studio>().Select(s => s.Name),
|
|
"director" => dbContext.Set<Director>().Select(d => d.Name),
|
|
"writer" => dbContext.Set<Writer>().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<Tag>()
|
|
.Where(t => t.ExternalTypeId != Tag.NfoCountryTypeId && t.ExternalTypeId != Tag.PlexNetworkTypeId)
|
|
.Select(t => t.Name),
|
|
"network" => dbContext.Set<Tag>()
|
|
.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<List<string>> GetContentRatingValues(
|
|
TvContext dbContext,
|
|
string qLower,
|
|
int limit,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
List<string> 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<string> split = raw
|
|
.SelectMany(cr => cr.Split('/'))
|
|
.Select(cr => cr.Trim())
|
|
.Where(cr => !string.IsNullOrEmpty(cr))
|
|
.Distinct();
|
|
|
|
return FilterSortTake(split, qLower, limit);
|
|
}
|
|
|
|
private static List<string> FilterSortTake(IEnumerable<string> values, string qLower, int limit) =>
|
|
values
|
|
.Where(v => v.ToLower().StartsWith(qLower))
|
|
.OrderBy(v => v)
|
|
.Take(limit)
|
|
.ToList();
|
|
}
|