feat: add autocomplete query detection to SearchMediaItems handler
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)
This commit is contained in:
+91
-40
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user