diff --git a/ErsatzTV.Application/Search/Queries/GetSearchFieldValuesHandler.cs b/ErsatzTV.Application/Search/Queries/GetSearchFieldValuesHandler.cs index af8a61b6b..eb5ca3c8d 100644 --- a/ErsatzTV.Application/Search/Queries/GetSearchFieldValuesHandler.cs +++ b/ErsatzTV.Application/Search/Queries/GetSearchFieldValuesHandler.cs @@ -26,8 +26,6 @@ public class GetSearchFieldValuesHandler(IDbContextFactory dbContextF int limit = request.Limit <= 0 ? DefaultLimit : Math.Clamp(request.Limit, 1, MaxLimit); string qLower = (request.Query ?? string.Empty).ToLower(); - await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); - // in-memory special cases (no DB query needed) switch (request.Name) { @@ -37,9 +35,14 @@ public class GetSearchFieldValuesHandler(IDbContextFactory dbContextF case "video_dynamic_range": return new SearchFieldValuesResponseModel( FilterSortTake(["hdr", "sdr"], qLower, limit)); - case "content_rating": - return new SearchFieldValuesResponseModel( - await GetContentRatingValues(dbContext, qLower, limit, cancellationToken)); + } + + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + if (request.Name == "content_rating") + { + return new SearchFieldValuesResponseModel( + await GetContentRatingValues(dbContext, qLower, limit, cancellationToken)); } IQueryable source = GetSource(dbContext, request.Name); diff --git a/ErsatzTV.Tests/Application/Search/GetSearchFieldValuesHandlerTests.cs b/ErsatzTV.Tests/Application/Search/GetSearchFieldValuesHandlerTests.cs index 222e9ab2c..c3836f4e0 100644 --- a/ErsatzTV.Tests/Application/Search/GetSearchFieldValuesHandlerTests.cs +++ b/ErsatzTV.Tests/Application/Search/GetSearchFieldValuesHandlerTests.cs @@ -125,4 +125,75 @@ public class GetSearchFieldValuesHandlerTests result.IsNone.ShouldBeTrue(); } + + [Test] + public async Task Matches_Case_Insensitive_Prefix() + { + await using (TvContext context = _db.CreateContext()) + { + context.Set().AddRange( + new Genre { Name = "Action" }, + new Genre { Name = "Comedy" }); + await context.SaveChangesAsync(); + } + + var handler = new GetSearchFieldValuesHandler(_db.Factory); + + Option result = await handler.Handle( + new GetSearchFieldValues("genre", "a", 50), + CancellationToken.None); + + result.IsSome.ShouldBeTrue(); + result.IfSome(r => r.Values.ShouldBe(new List { "Action" })); + } + + [Test] + public async Task Excludes_Network_And_Country_Tags_From_Tag_Field_And_Routes_Network_Tags_To_Network_Field() + { + await using (TvContext context = _db.CreateContext()) + { + context.Set().AddRange( + new Tag { Name = "PlainTag" }, + new Tag { Name = "HBO", ExternalTypeId = Tag.PlexNetworkTypeId }, + new Tag { Name = "USA", ExternalTypeId = Tag.NfoCountryTypeId }); + await context.SaveChangesAsync(); + } + + var handler = new GetSearchFieldValuesHandler(_db.Factory); + + Option tagResult = await handler.Handle( + new GetSearchFieldValues("tag", string.Empty, 50), + CancellationToken.None); + + tagResult.IsSome.ShouldBeTrue(); + tagResult.IfSome(r => r.Values.ShouldBe(new List { "PlainTag" })); + + Option networkResult = await handler.Handle( + new GetSearchFieldValues("network", string.Empty, 50), + CancellationToken.None); + + networkResult.IsSome.ShouldBeTrue(); + networkResult.IfSome(r => r.Values.ShouldBe(new List { "HBO" })); + } + + [Test] + public async Task Dedupes_Repeated_Values() + { + await using (TvContext context = _db.CreateContext()) + { + context.Set().AddRange( + new Genre { Name = "Action" }, + new Genre { Name = "Action" }); + await context.SaveChangesAsync(); + } + + var handler = new GetSearchFieldValuesHandler(_db.Factory); + + Option result = await handler.Handle( + new GetSearchFieldValues("genre", string.Empty, 50), + CancellationToken.None); + + result.IsSome.ShouldBeTrue(); + result.IfSome(r => r.Values.ShouldBe(new List { "Action" })); + } } diff --git a/ErsatzTV/Controllers/Api/SearchController.cs b/ErsatzTV/Controllers/Api/SearchController.cs index c191d4b06..b2153e374 100644 --- a/ErsatzTV/Controllers/Api/SearchController.cs +++ b/ErsatzTV/Controllers/Api/SearchController.cs @@ -191,11 +191,12 @@ public class SearchController(IMediator mediator) : ControllerBase [HttpGet("/api/v1/search/fields/{name}/values", Name = "GetSearchFieldValues")] [Tags("Search")] - [EndpointSummary("List distinct term values for a text search field")] + [EndpointSummary("List distinct database values for a text search field")] [EndpointDescription( - "Returns distinct term values from the search index for the given text field, filtered by an " + + "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 or is not a text field.")] + "404 when the field is unknown, is not a text field, or is a text field with no distinct-value " + + "source.")] [EndpointGroupName("general")] [ProducesResponseType(typeof(SearchFieldValuesResponseModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index f749ea132..4cd1e73a8 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -18040,8 +18040,8 @@ "tags": [ "Search" ], - "summary": "List distinct term values for a text search field", - "description": "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.", + "summary": "List distinct database values for a text search field", + "description": "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.", "operationId": "GetSearchFieldValues", "parameters": [ { diff --git a/docs/endpoint-index.md b/docs/endpoint-index.md index 3c1cafbc7..5f2eb6f1d 100644 --- a/docs/endpoint-index.md +++ b/docs/endpoint-index.md @@ -338,7 +338,7 @@ | GET | `/api/v1/search/artists` | SearchArtists | Search artists by name | | GET | `/api/v1/search/collections` | SearchCollections | Search collections by name | | GET | `/api/v1/search/fields` | GetSearchFields | List the filterable fields for the visual rule builder | -| GET | `/api/v1/search/fields/{name}/values` | GetSearchFieldValues | List distinct term values for a text search field | +| GET | `/api/v1/search/fields/{name}/values` | GetSearchFieldValues | List distinct database values for a text search field | | GET | `/api/v1/search/multi-collections` | SearchMultiCollections | Search multi collections by name | | GET | `/api/v1/search/smart-collections` | SearchSmartCollections | Search smart collections by name | | GET | `/api/v1/search/television-seasons` | SearchTelevisionSeasons | Search television seasons by name |