Adds GET /api/v1/search/fields/{name}/values?q=&limit= — the backend slice of the
visual rule builder's facet-value typeahead (#434). Enumerates distinct Lucene term
values for a text field via MultiFields.GetTerms + TermsEnum, filtered by a
case-insensitive prefix, limit clamped to [1,50]. 404s when the field is absent from
SearchFieldCatalog or is not type "text". ElasticSearchIndex (the optional external
backend) throws NotSupportedException for this method — its text fields are analyzed,
not keyword-mapped, so a terms aggregation isn't safe to guess at without verifying
against a live cluster.
Regenerated OpenAPI trio (v1.json, v1.d.ts, endpoint-index.md).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
238 lines
11 KiB
C#
238 lines
11 KiB
C#
using ErsatzTV.Application.MediaCollections;
|
|
using ErsatzTV.Application.MediaItems;
|
|
using ErsatzTV.Application.Search;
|
|
using ErsatzTV.Application.Search.Queries;
|
|
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Api.Scheduling;
|
|
using ErsatzTV.Core.Api.Search;
|
|
using ErsatzTV.Extensions;
|
|
using MediatR;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
namespace ErsatzTV.Controllers.Api;
|
|
|
|
[ApiController]
|
|
public class SearchController(IMediator mediator) : ControllerBase
|
|
{
|
|
private const int MaxPageSize = 100;
|
|
|
|
// all-items returns bare ids (cheap), so it tolerates a larger page than the item-returning
|
|
// /search endpoint; the clamp still bounds one response to <= 10 kinds * MaxAllItemsPageSize ids
|
|
// so a broad query can't materialize the whole index in one request (issue #293).
|
|
private const int DefaultAllItemsPageSize = 500;
|
|
private const int MaxAllItemsPageSize = 1000;
|
|
|
|
// Upper-bound the page number so pageNum * pageSize (the search skip) can't overflow int and 500 —
|
|
// no legitimate client pages past this, and it keeps skip + limit inside int range at MaxAllItemsPageSize.
|
|
private const int MaxAllItemsPageNum = 2_000_000;
|
|
|
|
[HttpGet("/api/v1/search", Name = "Search")]
|
|
[Tags("Search")]
|
|
[EndpointSummary("Search library items across all media kinds")]
|
|
[EndpointGroupName("general")]
|
|
[ProducesResponseType(typeof(SearchResultsResponseModel), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
|
public async Task<IActionResult> Search(
|
|
[FromQuery] string query = "",
|
|
[FromQuery] int pageNum = 0,
|
|
[FromQuery] int pageSize = 50,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(query))
|
|
{
|
|
return BaseError.New("A non-empty query is required").ToErrorResult();
|
|
}
|
|
|
|
int clampedPageNum = Math.Max(0, pageNum);
|
|
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
|
|
SearchResultsResponseModel result = await mediator.Send(
|
|
new GetSearchResults(query, clampedPageNum, clampedPageSize),
|
|
cancellationToken);
|
|
return new OkObjectResult(result);
|
|
}
|
|
|
|
[HttpGet("/api/v1/search/all-items", Name = "SearchAllItems")]
|
|
[Tags("Search")]
|
|
[EndpointSummary("Search library items across all media kinds and return raw id lists")]
|
|
[EndpointDescription(
|
|
"Returns matching item ids, grouped by media kind, one clamped page per kind plus per-kind " +
|
|
"total counts. Used by the SPA's \"add all to collection/playlist\" flow, which pages to " +
|
|
"completeness. Paging bounds a broad query so it can't materialize the whole index in one " +
|
|
"request (issue #293).")]
|
|
[EndpointGroupName("general")]
|
|
[ProducesResponseType(typeof(SearchResultAllItemsResponseModel), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
|
public async Task<IActionResult> SearchAllItems(
|
|
[FromQuery] string query = "",
|
|
[FromQuery] int pageNum = 0,
|
|
[FromQuery] int pageSize = DefaultAllItemsPageSize,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(query))
|
|
{
|
|
return BaseError.New("A non-empty query is required").ToErrorResult();
|
|
}
|
|
|
|
int clampedPageNum = Math.Clamp(pageNum, 0, MaxAllItemsPageNum);
|
|
int clampedPageSize = Math.Clamp(pageSize, 1, MaxAllItemsPageSize);
|
|
SearchResultAllItemsViewModel result = await mediator.Send(
|
|
new QuerySearchIndexAllItems(query, clampedPageNum, clampedPageSize),
|
|
cancellationToken);
|
|
return new OkObjectResult(Project(result));
|
|
}
|
|
|
|
[HttpGet("/api/v1/search/collections", Name = "SearchCollections")]
|
|
[Tags("Search")]
|
|
[EndpointSummary("Search collections by name")]
|
|
[EndpointDescription("Returns matching collections as {id, name} options for scheduling editors.")]
|
|
[EndpointGroupName("general")]
|
|
[ProducesResponseType(typeof(List<SchedulingPickerOptionResponseModel>), StatusCodes.Status200OK)]
|
|
public async Task<List<SchedulingPickerOptionResponseModel>> SearchCollections(
|
|
[FromQuery] string query = "",
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
List<MediaCollectionViewModel> results = await mediator.Send(
|
|
new SearchCollections(query ?? string.Empty),
|
|
cancellationToken);
|
|
return results.Map(c => new SchedulingPickerOptionResponseModel(c.Id, c.Name)).ToList();
|
|
}
|
|
|
|
[HttpGet("/api/v1/search/television-shows", Name = "SearchTelevisionShows")]
|
|
[Tags("Search")]
|
|
[EndpointSummary("Search television shows by name")]
|
|
[EndpointDescription("Returns matching television shows as {id, name} options for scheduling editors.")]
|
|
[EndpointGroupName("general")]
|
|
[ProducesResponseType(typeof(List<SchedulingPickerOptionResponseModel>), StatusCodes.Status200OK)]
|
|
public async Task<List<SchedulingPickerOptionResponseModel>> SearchTelevisionShows(
|
|
[FromQuery] string query = "",
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
List<NamedMediaItemViewModel> results = await mediator.Send(
|
|
new SearchTelevisionShows(query ?? string.Empty),
|
|
cancellationToken);
|
|
return results.Map(s => new SchedulingPickerOptionResponseModel(s.MediaItemId, s.Name)).ToList();
|
|
}
|
|
|
|
[HttpGet("/api/v1/search/television-seasons", Name = "SearchTelevisionSeasons")]
|
|
[Tags("Search")]
|
|
[EndpointSummary("Search television seasons by name")]
|
|
[EndpointDescription("Returns matching television seasons as {id, name} options for scheduling editors.")]
|
|
[EndpointGroupName("general")]
|
|
[ProducesResponseType(typeof(List<SchedulingPickerOptionResponseModel>), StatusCodes.Status200OK)]
|
|
public async Task<List<SchedulingPickerOptionResponseModel>> SearchTelevisionSeasons(
|
|
[FromQuery] string query = "",
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
List<NamedMediaItemViewModel> results = await mediator.Send(
|
|
new SearchTelevisionSeasons(query ?? string.Empty),
|
|
cancellationToken);
|
|
return results.Map(s => new SchedulingPickerOptionResponseModel(s.MediaItemId, s.Name)).ToList();
|
|
}
|
|
|
|
[HttpGet("/api/v1/search/smart-collections", Name = "SearchSmartCollections")]
|
|
[Tags("Search")]
|
|
[EndpointSummary("Search smart collections by name")]
|
|
[EndpointDescription("Returns matching smart collections as {id, name} options for scheduling editors.")]
|
|
[EndpointGroupName("general")]
|
|
[ProducesResponseType(typeof(List<SchedulingPickerOptionResponseModel>), StatusCodes.Status200OK)]
|
|
public async Task<List<SchedulingPickerOptionResponseModel>> SearchSmartCollections(
|
|
[FromQuery] string query = "",
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
List<SmartCollectionViewModel> results = await mediator.Send(
|
|
new SearchSmartCollections(query ?? string.Empty),
|
|
cancellationToken);
|
|
return results.Map(c => new SchedulingPickerOptionResponseModel(c.Id, c.Name)).ToList();
|
|
}
|
|
|
|
[HttpGet("/api/v1/search/artists", Name = "SearchArtists")]
|
|
[Tags("Search")]
|
|
[EndpointSummary("Search artists by name")]
|
|
[EndpointDescription("Returns matching artists as {id, name} options for scheduling editors.")]
|
|
[EndpointGroupName("general")]
|
|
[ProducesResponseType(typeof(List<SchedulingPickerOptionResponseModel>), StatusCodes.Status200OK)]
|
|
public async Task<List<SchedulingPickerOptionResponseModel>> SearchArtists(
|
|
[FromQuery] string query = "",
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
List<NamedMediaItemViewModel> results = await mediator.Send(
|
|
new SearchArtists(query ?? string.Empty),
|
|
cancellationToken);
|
|
return results.Map(a => new SchedulingPickerOptionResponseModel(a.MediaItemId, a.Name)).ToList();
|
|
}
|
|
|
|
[HttpGet("/api/v1/search/multi-collections", Name = "SearchMultiCollections")]
|
|
[Tags("Search")]
|
|
[EndpointSummary("Search multi collections by name")]
|
|
[EndpointDescription("Returns matching multi collections as {id, name} options for scheduling editors.")]
|
|
[EndpointGroupName("general")]
|
|
[ProducesResponseType(typeof(List<SchedulingPickerOptionResponseModel>), StatusCodes.Status200OK)]
|
|
public async Task<List<SchedulingPickerOptionResponseModel>> SearchMultiCollections(
|
|
[FromQuery] string query = "",
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
List<MultiCollectionViewModel> results = await mediator.Send(
|
|
new SearchMultiCollections(query ?? string.Empty),
|
|
cancellationToken);
|
|
return results.Map(c => new SchedulingPickerOptionResponseModel(c.Id, c.Name)).ToList();
|
|
}
|
|
|
|
[HttpGet("/api/v1/search/fields", Name = "GetSearchFields")]
|
|
[Tags("Search")]
|
|
[EndpointSummary("List the filterable fields for the visual rule builder")]
|
|
[EndpointDescription(
|
|
"Returns the curated catalog of searchable fields (name, friendly label, type, UI group, and " +
|
|
"allowed values for enum fields). Drives the SmartCollection rule builder and is introspectable by MCP.")]
|
|
[EndpointGroupName("general")]
|
|
[ProducesResponseType(typeof(List<SearchFieldResponseModel>), StatusCodes.Status200OK)]
|
|
public Task<List<SearchFieldResponseModel>> GetSearchFields(CancellationToken cancellationToken) =>
|
|
mediator.Send(new GetSearchFieldCatalog(), cancellationToken);
|
|
|
|
[HttpGet("/api/v1/search/fields/{name}/values", Name = "GetSearchFieldValues")]
|
|
[Tags("Search")]
|
|
[EndpointSummary("List distinct term values for a text search field")]
|
|
[EndpointDescription(
|
|
"Returns distinct term values from the search index for the given text field, filtered by an " +
|
|
"optional case-insensitive prefix. Powers the visual rule builder's facet-value typeahead. " +
|
|
"404 when the field is unknown or is not a text field.")]
|
|
[EndpointGroupName("general")]
|
|
[ProducesResponseType(typeof(SearchFieldValuesResponseModel), StatusCodes.Status200OK)]
|
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
|
public async Task<IActionResult> GetSearchFieldValues(
|
|
[FromRoute] string name,
|
|
[FromQuery] string q = "",
|
|
[FromQuery] int limit = 50,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Option<SearchFieldValuesResponseModel> result = await mediator.Send(
|
|
new GetSearchFieldValues(name, q ?? string.Empty, limit),
|
|
cancellationToken);
|
|
return result.ToGetResult();
|
|
}
|
|
|
|
private static SearchResultAllItemsResponseModel Project(SearchResultAllItemsViewModel vm) =>
|
|
new(
|
|
vm.MovieIds,
|
|
vm.ShowIds,
|
|
vm.SeasonIds,
|
|
vm.EpisodeIds,
|
|
vm.ArtistIds,
|
|
vm.MusicVideoIds,
|
|
vm.OtherVideoIds,
|
|
vm.SongIds,
|
|
vm.ImageIds,
|
|
vm.RemoteStreamIds,
|
|
new SearchResultAllItemsTotalsResponseModel(
|
|
vm.Totals.MovieCount,
|
|
vm.Totals.ShowCount,
|
|
vm.Totals.SeasonCount,
|
|
vm.Totals.EpisodeCount,
|
|
vm.Totals.ArtistCount,
|
|
vm.Totals.MusicVideoCount,
|
|
vm.Totals.OtherVideoCount,
|
|
vm.Totals.SongCount,
|
|
vm.Totals.ImageCount,
|
|
vm.Totals.RemoteStreamCount));
|
|
}
|