using ErsatzTV.Application.MediaCollections; using ErsatzTV.Application.MediaItems; using ErsatzTV.Application.Search; 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; [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] 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 every matching item's id, grouped by media kind, with no paging. Used by the SPA's " + "\"add all to collection/playlist\" flow to materialize ids before calling the add endpoints.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(SearchResultAllItemsResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] public async Task SearchAllItems( [FromQuery] string query = "", CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(query)) { return BaseError.New("A non-empty query is required").ToErrorResult(); } SearchResultAllItemsViewModel result = await mediator.Send( new QuerySearchIndexAllItems(query), 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(); } 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); }