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
|
// 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 {
|
func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||||
query := c.QueryParam("q")
|
|
||||||
|
|
||||||
// Safely get user from context
|
// Safely get user from context
|
||||||
userID, ok := c.Get("user").(database.Users)
|
userID, ok := c.Get("user").(database.Users)
|
||||||
if !ok {
|
if !ok {
|
||||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryID := c.QueryParam("library_id")
|
// Detect autocomplete queries first (author=value, genre=value, etc.)
|
||||||
|
if author := c.QueryParam("author"); author != "" {
|
||||||
if query == "" {
|
return mh.handleFieldValuesSearch(c, userID.ID, "author", author)
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "query parameter 'q' is required"})
|
|
||||||
}
|
}
|
||||||
|
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
|
// Validate library_id if provided
|
||||||
var libUUID pgtype.UUID
|
var libUUID pgtype.UUID
|
||||||
@@ -1443,47 +1456,45 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
|||||||
libUUID = pgtype.UUID{Bytes: lib, Valid: true}
|
libUUID = pgtype.UUID{Bytes: lib, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
limit := int32(50)
|
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
||||||
offset := int32(0)
|
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
|
// Build search params
|
||||||
partialParams := database.SearchMediaItemsParams{
|
params := services.SearchParams{
|
||||||
SearchPattern: pgtype.Text{String: searchPattern, Valid: true},
|
UserID: userID.ID,
|
||||||
UserID: userID.ID,
|
LibraryID: libUUID,
|
||||||
Limit: pgtype.Int4{Int32: limit, Valid: true},
|
SearchQuery: query,
|
||||||
Offset: pgtype.Int4{Int32: offset, Valid: true},
|
AuthorFilter: authorFilter,
|
||||||
LibraryID: libUUID, // May be invalid (empty)
|
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 {
|
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()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(partialResults) > 0 {
|
if len(results) == 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 {
|
|
||||||
return c.JSON(http.StatusNotFound, map[string]interface{}{
|
return c.JSON(http.StatusNotFound, map[string]interface{}{
|
||||||
"error": "no results found",
|
"error": "no results found",
|
||||||
"query": query,
|
"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
|
// getFullFilePath returns the absolute filesystem path for a media item
|
||||||
|
|||||||
Reference in New Issue
Block a user