Files
Christopher Mayor 468519fde1 feat: semantic search with Qdrant + Ollama embeddings
- Add SemanticSearchService with embed() + searchQdrant() methods
- Add GET /api/search/semantic endpoint (?q=query&k=5)
- Wire SemanticSearchService into router and cmd/server/main.go
- Add SemanticSearch React page with results + similarity scores
- Add 'Semantic Search' nav link in App.tsx
- Add unit tests with mocked Ollama + Qdrant HTTP servers (4 tests, all passing)
- Add GitHub issue templates (bug report, feature request)
- Add pull request template
2026-04-24 11:21:26 -07:00

167 lines
4.8 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/labstack/echo/v4"
)
func searchReleases(svc *service.SearchService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second)
defer cancel()
query := c.QueryParam("query")
if query == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "query parameter is required"})
}
mediaType := c.QueryParam("media_type")
var indexerIDs []int64
if ids := c.QueryParam("indexer_ids"); ids != "" {
for _, idStr := range strings.Split(ids, ",") {
idStr = strings.TrimSpace(idStr)
if id, err := strconv.ParseInt(idStr, 10, 64); err == nil {
indexerIDs = append(indexerIDs, id)
}
}
}
req := service.SearchRequest{
Query: query,
MediaType: mediaType,
IndexerIDs: indexerIDs,
}
results, err := svc.Search(ctx, req)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"data": results,
"total": len(results),
})
}
}
func semanticSearch(svc *service.SemanticSearchService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second)
defer cancel()
query := c.QueryParam("q")
if query == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "q parameter is required"})
}
k := 5
if kStr := c.QueryParam("k"); kStr != "" {
if kVal, err := strconv.Atoi(kStr); err == nil && kVal > 0 && kVal <= 50 {
k = kVal
}
}
results, err := svc.Search(ctx, query, k)
if err != nil {
slog.Error("semantic search failed", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"results": results,
})
}
}
func grabRelease(svc *service.SearchService, dcSvc *service.DownloadClientService, queueSvc *service.QueueService, safetySvc *service.SafetyService, activitySvc *service.ActivityService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second)
defer cancel()
var req service.GrabRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
if req.DownloadURL == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "download_url is required"})
}
if req.Title == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "title is required"})
}
if req.MediaID == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "media_id is required"})
}
// Safety check: block dangerous file extensions before download
if safetySvc != nil {
block := safetySvc.Check(req.Title, req.DownloadURL)
if block != nil {
if activitySvc != nil {
activitySvc.LogAsync(service.LogEntry{
EventType: "safety_block",
MediaID: &req.MediaID,
MediaType: &req.MediaType,
Title: req.Title,
Description: &block.Reason,
Data: json.RawMessage(fmt.Sprintf(`{"extension":"%s","indexer":"%s"}`, block.MatchedExtension, req.IndexerName)),
})
}
return c.JSON(http.StatusUnprocessableEntity, map[string]interface{}{
"error": block.Reason,
"blocked": true,
})
}
}
result, err := svc.Grab(ctx, req, dcSvc)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
qualityJSON, _ := json.Marshal(req.Quality)
_, err = queueSvc.CreateQueueEntry(ctx, service.CreateQueueEntryRequest{
MediaID: req.MediaID,
MediaType: req.MediaType,
ReleaseTitle: req.Title,
Indexer: req.IndexerName,
DownloadClient: result.ClientName,
Quality: qualityJSON,
Protocol: result.Protocol,
DownloadID: result.DownloadID,
})
if err != nil {
slog.Error("failed to create queue entry", "error", err)
}
// Log successful grab activity
if activitySvc != nil {
activitySvc.LogAsync(service.LogEntry{
EventType: "grab",
MediaID: &req.MediaID,
MediaType: &req.MediaType,
Title: fmt.Sprintf("Grabbed %s", req.Title),
Description: &req.IndexerName,
Data: json.RawMessage(fmt.Sprintf(`{"release":"%s","client":"%s","protocol":"%s"}`, req.Title, result.ClientName, result.Protocol)),
})
}
return c.JSON(http.StatusCreated, map[string]interface{}{
"queue_id": result.QueueID,
"download_id": result.DownloadID,
"client": result.ClientName,
"protocol": result.Protocol,
})
}
}