using System.ComponentModel; 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 Search( [FromQuery] string query = "", [FromQuery] [Description("0-based page index: the first page is 0, not 1. A negative value is clamped to 0.")] int pageNum = 0, [FromQuery] [Description("Rows per page (default 50); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.")] 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 SearchAllItems( [FromQuery] string query = "", [FromQuery] [Description("0-based page index: the first page is 0, not 1. A negative value is clamped to 0. Unlike the other paged endpoints this one is also bounded ABOVE, at 2000000, so that pageNum * pageSize cannot overflow; a larger value is clamped down to that maximum rather than rejected.")] int pageNum = 0, [FromQuery] [Description("Rows per page (default 500); capped at 1000 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.")] 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), StatusCodes.Status200OK)] public async Task> SearchCollections( [FromQuery] string query = "", CancellationToken cancellationToken = default) { List 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), StatusCodes.Status200OK)] public async Task> SearchTelevisionShows( [FromQuery] string query = "", CancellationToken cancellationToken = default) { List 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), StatusCodes.Status200OK)] public async Task> SearchTelevisionSeasons( [FromQuery] string query = "", CancellationToken cancellationToken = default) { List 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), StatusCodes.Status200OK)] public async Task> SearchSmartCollections( [FromQuery] string query = "", CancellationToken cancellationToken = default) { List 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), StatusCodes.Status200OK)] public async Task> SearchArtists( [FromQuery] string query = "", CancellationToken cancellationToken = default) { List 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), StatusCodes.Status200OK)] public async Task> SearchMultiCollections( [FromQuery] string query = "", CancellationToken cancellationToken = default) { List 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), StatusCodes.Status200OK)] public Task> GetSearchFields(CancellationToken cancellationToken) => mediator.Send(new GetSearchFieldCatalog(), cancellationToken); [HttpGet("/api/v1/search/fields/{name}/values", Name = "GetSearchFieldValues")] [Tags("Search")] [EndpointSummary("List distinct database values for a text search field")] [EndpointDescription( "Returns distinct whole values from the database 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, is not a text field, or is a text field with no distinct-value " + "source. The final filter, dedup and ordering applied to the response are ordinal and not " + "culture-dependent; note that fields sourced by a plain database query are additionally " + "pre-filtered by the database collation first, which on SQLite is ASCII-only. The list-valued " + "music fields (artist, album_artist) are bounded best-effort: the server reads a bounded number " + "of song rows per request, so a library larger than that bound may yield a subset of the matches.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(SearchFieldValuesResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetSearchFieldValues( [FromRoute] string name, [FromQuery] string q = "", [FromQuery] int limit = 50, CancellationToken cancellationToken = default) { Option 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)); }