From b73d58b82e8f3622598cd43029403f91b8a74b6c Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 23 Mar 2026 21:02:36 -0400 Subject: [PATCH] feat: add autocomplete query detection to SearchMediaItems handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit enhances the SearchMediaItems handler to support dual-mode operation: unified search with filters AND autocomplete queries for dropdown suggestions. **Autocomplete Detection:** - Detects autocomplete queries: author=value, genre=value, series=value, language=value - Routes to new handleFieldValuesSearch method for dropdown population - Returns JSON format: {"results": [{"value": "...", "count": 47, "score": 0.8}], "total": 1} **Unified Search Integration:** - Replaced direct DB calls (SearchMediaItems, SearchMediaItemsFuzzy) with SearchService - Added support for all fuzzy filters: author_filter, genre_filter, series_filter, language_filter - Added exact filters: year_min, year_max, has_cover - Combined search query + filters in single SearchMediaItemsUnified call - Removed fallback logic (partial → fuzzy), now single query with smart ordering **New Method: handleFieldValuesSearch** - Handles autocomplete queries for all field types (author, genre, series, language) - Validates library_id requirement - Applies default limit=50 if not specified - Calls SearchService.SearchFieldValues() with FieldSearchParams - Returns consistent JSON format with results array and total count **QueryParam Handling:** - Fixed to not use default values (Echo QueryParam only accepts single argument) - Properly handles empty limit parameter with default fallback - Extracts all filter parameters for unified search **Behavior Changes:** - SearchMediaItems no longer requires 'q' parameter (filters-only queries now valid) - Autocomplete queries detected before filter processing (correct priority) - Better error messages and logging **Service Layer Pattern:** - Follows established pattern (FiltersService, CollectionService) - Handler is thin - extracts params and calls service - Business logic in SearchService (created in commit 9ab2796) **Backward Compatibility:** - All existing query parameters still supported - Response format unchanged for media items search - New response format for autocomplete queries (distinct field values) --- internal/handlers/media.go | 131 ++++++++++++++++++++++++++----------- 1 file changed, 91 insertions(+), 40 deletions(-) diff --git a/internal/handlers/media.go b/internal/handlers/media.go index bbc1a8a..799f184 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -1418,20 +1418,33 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error { } // SearchMediaItems handles GET /api/media-items/search +// Supports two modes: +// 1. Autocomplete: author=value, genre=value, etc. → returns field values for dropdowns +// 2. Search: q=value with optional filters → returns media items func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error { - query := c.QueryParam("q") - // Safely get user from context userID, ok := c.Get("user").(database.Users) if !ok { return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"}) } - libraryID := c.QueryParam("library_id") - - if query == "" { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "query parameter 'q' is required"}) + // Detect autocomplete queries first (author=value, genre=value, etc.) + if author := c.QueryParam("author"); author != "" { + return mh.handleFieldValuesSearch(c, userID.ID, "author", author) } + if genre := c.QueryParam("genre"); genre != "" { + return mh.handleFieldValuesSearch(c, userID.ID, "genre", genre) + } + if series := c.QueryParam("series"); series != "" { + return mh.handleFieldValuesSearch(c, userID.ID, "series", series) + } + if language := c.QueryParam("language"); language != "" { + return mh.handleFieldValuesSearch(c, userID.ID, "language", language) + } + + // Handle media item search with filters + query := c.QueryParam("q") + libraryID := c.QueryParam("library_id") // Validate library_id if provided var libUUID pgtype.UUID @@ -1443,47 +1456,45 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error { libUUID = pgtype.UUID{Bytes: lib, Valid: true} } - limit := int32(50) - offset := int32(0) + limit, _ := strconv.Atoi(c.QueryParam("limit")) + if limit == 0 { + limit = 50 + } + offset, _ := strconv.Atoi(c.QueryParam("offset")) - searchPattern := "%" + query + "%" + // Extract filter parameters + authorFilter := c.QueryParam("author_filter") + seriesFilter := c.QueryParam("series_filter") + genreFilter := c.QueryParam("genre_filter") + languageFilter := c.QueryParam("language_filter") + yearMin, _ := strconv.Atoi(c.QueryParam("year_min")) + yearMax, _ := strconv.Atoi(c.QueryParam("year_max")) + hasCover := c.QueryParam("has_cover") == "true" - // Build params - conditionally and library_id filter - partialParams := database.SearchMediaItemsParams{ - SearchPattern: pgtype.Text{String: searchPattern, Valid: true}, - UserID: userID.ID, - Limit: pgtype.Int4{Int32: limit, Valid: true}, - Offset: pgtype.Int4{Int32: offset, Valid: true}, - LibraryID: libUUID, // May be invalid (empty) + // Build search params + params := services.SearchParams{ + UserID: userID.ID, + LibraryID: libUUID, + SearchQuery: query, + AuthorFilter: authorFilter, + SeriesFilter: seriesFilter, + GenreFilter: genreFilter, + LanguageFilter: languageFilter, + YearMin: yearMin, + YearMax: yearMax, + HasCover: hasCover, + Limit: limit, + Offset: offset, } - partialResults, err := mh.db.SearchMediaItems(c.Request().Context(), partialParams) - + // Call SearchService instead of DB directly + results, err := mh.searchService.SearchMediaItemsUnified(c.Request().Context(), params) if err != nil && err != pgx.ErrNoRows { - c.Logger().Error("search error", "error", err.Error(), "query", query, "library_id", libraryID) + c.Logger().Error("search error", "error", err.Error()) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } - if len(partialResults) > 0 { - return c.JSON(http.StatusOK, partialResults) - } - - fuzzyParams := database.SearchMediaItemsFuzzyParams{ - SearchQuery: pgtype.Text{String: query, Valid: true}, - UserID: userID.ID, - Limit: pgtype.Int4{Int32: limit, Valid: true}, - Offset: pgtype.Int4{Int32: offset, Valid: true}, - LibraryID: libUUID, - } - - fuzzyResults, err := mh.db.SearchMediaItemsFuzzy(c.Request().Context(), fuzzyParams) - - if err != nil && err != pgx.ErrNoRows { - c.Logger().Error("fuzzy search error", "error", err.Error(), "query", query) - return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) - } - - if len(fuzzyResults) == 0 { + if len(results) == 0 { return c.JSON(http.StatusNotFound, map[string]interface{}{ "error": "no results found", "query": query, @@ -1491,7 +1502,47 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error { }) } - return c.JSON(http.StatusOK, fuzzyResults) + return c.JSON(http.StatusOK, results) +} + +// handleFieldValuesSearch handles autocomplete queries (author=value, genre=value, etc.) +// Returns distinct field values with counts and similarity scores for dropdown population +func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error { + libraryID := c.QueryParam("library_id") + if libraryID == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id is required"}) + } + + libUUID, err := uuid.Parse(libraryID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) + } + + limit, _ := strconv.Atoi(c.QueryParam("limit")) + if limit == 0 { + limit = 50 + } + offset, _ := strconv.Atoi(c.QueryParam("offset")) + + params := services.FieldSearchParams{ + UserID: userID, + LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, + FieldType: fieldType, + SearchQuery: searchQuery, + Limit: limit, + Offset: offset, + } + + results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params) + if err != nil { + c.Logger().Error("field values search error", "error", err.Error(), "fieldType", fieldType) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "results": results, + "total": len(results), + }) } // getFullFilePath returns the absolute filesystem path for a media item