feat(176): search field catalog query + curated list

This commit is contained in:
2026-07-18 02:41:50 +02:00
parent aa4127fcef
commit 41df582032
5 changed files with 145 additions and 0 deletions
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Search;
namespace ErsatzTV.Application.Search.Queries;
public record GetSearchFieldCatalog : IRequest<List<SearchFieldResponseModel>>;
@@ -0,0 +1,11 @@
using ErsatzTV.Core.Api.Search;
namespace ErsatzTV.Application.Search.Queries;
public class GetSearchFieldCatalogHandler : IRequestHandler<GetSearchFieldCatalog, List<SearchFieldResponseModel>>
{
public Task<List<SearchFieldResponseModel>> Handle(
GetSearchFieldCatalog request,
CancellationToken cancellationToken) =>
Task.FromResult(SearchFieldCatalog.Fields);
}
@@ -0,0 +1,58 @@
using ErsatzTV.Core.Api.Search;
namespace ErsatzTV.Application.Search;
public static class SearchFieldCatalog
{
private static readonly string[] None = [];
// Allowed values for the `type` enum — mirrors the lowercase tokens the Lucene index stores for
// the `type` field (see LuceneSearchIndex.TypeField and its *Type constants).
private static readonly string[] ItemTypes =
[
"movie", "show", "season", "episode", "artist", "music_video", "other_video", "song", "image",
"remote_stream"
];
public static readonly List<SearchFieldResponseModel> Fields =
[
// General
new("title", "Title", "text", "General", None),
new("genre", "Genre", "text", "General", None),
new("tag", "Tag", "text", "General", None),
new("plot", "Plot", "fulltext", "General", None),
new("content_rating", "Content rating", "text", "General", None),
new("studio", "Studio", "text", "General", None),
new("collection", "Collection", "text", "General", None),
new("state", "State", "text", "General", None),
new("type", "Item type", "enum", "General", ItemTypes),
// TV
new("network", "Network", "text", "TV", None),
new("show_title", "Show title", "text", "TV", None),
new("show_genre", "Show genre", "text", "TV", None),
new("season_number", "Season number", "number", "TV", None),
new("episode_number", "Episode number", "number", "TV", None),
// Movie / People
new("director", "Director", "text", "Movie", None),
new("writer", "Writer", "text", "Movie", None),
new("actor", "Actor", "text", "Movie", None),
// Music
new("artist", "Artist", "text", "Music", None),
new("album", "Album", "text", "Music", None),
new("album_artist", "Album artist", "text", "Music", None),
// Technical
new("minutes", "Duration (min)", "number", "Technical", None),
new("height", "Height (px)", "number", "Technical", None),
new("width", "Width (px)", "number", "Technical", None),
new("video_codec", "Video codec", "text", "Technical", None),
new("video_dynamic_range", "Dynamic range", "text", "Technical", None),
// Dates
new("added_date", "Date added", "date", "Dates", None),
new("release_date", "Release date", "date", "Dates", None)
];
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Api.Search;
/// <summary>
/// One filterable field in the visual rule builder's catalog.
/// <c>Type</c> is one of: text, fulltext, number, date, enum.
/// <c>Values</c> is populated only for <c>enum</c> fields (allowed dropdown values); empty otherwise.
/// </summary>
public record SearchFieldResponseModel(string Name, string Label, string Type, string Group, string[] Values);
@@ -0,0 +1,63 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.Search.Queries;
using ErsatzTV.Core.Api.Search;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Search;
[TestFixture]
public class GetSearchFieldCatalogHandlerTests
{
[Test]
public async Task Returns_Curated_Catalog_Without_Internal_Fields()
{
var handler = new GetSearchFieldCatalogHandler();
List<SearchFieldResponseModel> result = await handler.Handle(new GetSearchFieldCatalog(), CancellationToken.None);
result.ShouldNotBeEmpty();
result.Select(f => f.Name).ShouldNotContain("id");
result.Select(f => f.Name).ShouldNotContain("tag_full");
result.Select(f => f.Name).ShouldNotContain("library_folder_id");
}
[Test]
public async Task Every_Field_Has_A_Known_Type_And_A_Group()
{
var handler = new GetSearchFieldCatalogHandler();
string[] knownTypes = ["text", "fulltext", "number", "date", "enum"];
List<SearchFieldResponseModel> result = await handler.Handle(new GetSearchFieldCatalog(), CancellationToken.None);
foreach (SearchFieldResponseModel field in result)
{
knownTypes.ShouldContain(field.Type);
field.Group.ShouldNotBeNullOrWhiteSpace();
field.Label.ShouldNotBeNullOrWhiteSpace();
}
}
[Test]
public async Task Enum_Fields_Carry_NonEmpty_Values_And_NonEnum_Do_Not()
{
var handler = new GetSearchFieldCatalogHandler();
List<SearchFieldResponseModel> result = await handler.Handle(new GetSearchFieldCatalog(), CancellationToken.None);
foreach (SearchFieldResponseModel field in result)
{
if (field.Type == "enum")
{
field.Values.ShouldNotBeEmpty();
}
else
{
field.Values.ShouldBeEmpty();
}
}
}
}