Files
unified-media-manager/internal/api/router.go
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

227 lines
8.4 KiB
Go

package api
import (
"net/http"
"github.com/TopherMayor/unified-media-manager/internal/config"
"github.com/TopherMayor/unified-media-manager/internal/db"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/TopherMayor/unified-media-manager/internal/worker"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
type Services struct {
DB *db.DB
Media *service.MediaService
Queue *service.QueueService
Indexer *service.IndexerService
Blocklist *service.BlocklistService
Dashboard *service.DashboardService
Quality *service.QualityService
DownloadClient *service.DownloadClientService
Search *service.SearchService
Import *service.ImportService
Metadata *service.MetadataService
Subtitle *service.SubtitleService
RootFolder *service.RootFolderService
Tag *service.TagService
Scheduler *worker.Scheduler
User *service.UserService
Activity *service.ActivityService
Safety *service.SafetyService
Request *service.RequestService
Notification *service.NotificationService
Discover *service.DiscoverService
MediaDetail *service.MediaDetailService
Calendar *service.CalendarService
SemanticSearch *service.SemanticSearchService
}
func NewRouter(cfg *config.Config, svc *Services) *echo.Echo {
e := echo.New()
e.HideBanner = true
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{cfg.FrontendURL},
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
}))
e.Use(cacheControlMiddleware())
e.GET("/health/live", healthLive)
e.GET("/health/ready", healthReady(svc.DB, cfg))
g := e.Group("/api")
g.GET("/media", listMedia(svc.Media))
g.GET("/media/:type/:id", getMedia(svc.Media))
g.GET("/media/:type/:id/detail", getFullMediaDetail(svc.MediaDetail))
g.POST("/media", createMedia(svc.Media))
g.PUT("/media/:type/:id", updateMedia(svc.Media))
g.DELETE("/media/:type/:id", deleteMedia(svc.Media))
g.GET("/search", searchMedia(svc.Media))
g.GET("/search/missing", searchMissing(svc.Media))
g.GET("/search/upgrades", searchUpgrades(svc.Media))
g.GET("/queue", listQueue(svc.Queue))
g.DELETE("/queue/:id", deleteQueueItem(svc.Queue))
g.DELETE("/queue/batch", batchDeleteQueue(svc.Queue))
g.POST("/queue/clear", clearQueue(svc.Queue))
g.POST("/queue/:id/retry", retryQueueItem(svc.Queue))
g.POST("/queue/retry-failed", retryFailedQueue(svc.Queue))
g.GET("/blocklist", listBlocklist(svc.Blocklist))
g.DELETE("/blocklist/:id", deleteBlocklistItem(svc.Blocklist))
g.DELETE("/blocklist", clearBlocklist(svc.Blocklist))
g.DELETE("/blocklist/expired", clearExpiredBlocklist(svc.Blocklist))
g.POST("/blocklist", addBlocklistItem(svc.Blocklist))
g.GET("/indexers", listIndexers(svc.Indexer))
g.POST("/indexers", createIndexer(svc.Indexer))
g.POST("/indexers/validate-cardigann", validateCardigannDefinition())
g.PUT("/indexers/:id", updateIndexer(svc.Indexer))
g.DELETE("/indexers/:id", deleteIndexer(svc.Indexer))
g.POST("/indexers/:id/test", testIndexer(svc.Indexer))
g.GET("/indexers/:id/stats", indexerStats(svc.Indexer))
g.GET("/dashboard", dashboard(svc.Dashboard))
g.GET("/activity", listActivity(svc.Activity))
g.GET("/quality-profiles", listQualityProfiles(svc.Quality))
g.POST("/quality-profiles", createQualityProfile(svc.Quality))
g.PUT("/quality-profiles/:id", updateQualityProfile(svc.Quality))
g.DELETE("/quality-profiles/:id", deleteQualityProfile(svc.Quality))
g.GET("/download-clients", listDownloadClients(svc.DownloadClient))
g.POST("/download-clients", createDownloadClient(svc.DownloadClient))
g.PUT("/download-clients/:id", updateDownloadClient(svc.DownloadClient))
g.DELETE("/download-clients/:id", deleteDownloadClient(svc.DownloadClient))
g.POST("/download-clients/:id/test", testDownloadClient(svc.DownloadClient))
g.GET("/releases/search", searchReleases(svc.Search))
g.POST("/releases/grab", grabRelease(svc.Search, svc.DownloadClient, svc.Queue, svc.Safety, svc.Activity))
g.POST("/imports/trigger", triggerImport(svc.Import))
g.GET("/imports/history", listImportHistory(svc.Import, svc.DB))
g.POST("/media/:type/:id/refresh-metadata", refreshMetadata(svc.Metadata))
g.POST("/media/refresh-all", refreshAllMetadata(svc.Metadata))
g.GET("/images/:type/:filename", serveImage(cfg.ImageDir))
g.GET("/media/:type/:id/subtitles/search", searchSubtitles(svc.Subtitle, svc.Media))
g.POST("/media/:type/:id/subtitles/download", downloadSubtitle(svc.Subtitle, svc.Media))
g.POST("/media/:type/:id/subtitles/extract", extractSubtitles(svc.Subtitle, svc.Media))
g.GET("/root-folders", listRootFolders(svc.RootFolder))
g.POST("/root-folders", createRootFolder(svc.RootFolder))
g.DELETE("/root-folders/:id", deleteRootFolder(svc.RootFolder))
g.GET("/tags", listTags(svc.Tag))
g.POST("/tags", createTag(svc.Tag))
g.DELETE("/tags/:id", deleteTag(svc.Tag))
if svc.Scheduler != nil {
g.GET("/workers", listWorkers(svc.Scheduler))
g.GET("/workers/:name/history", workerHistory(svc.Scheduler))
g.PUT("/workers/:name", updateWorker(svc.Scheduler))
g.POST("/workers/:name/trigger", triggerWorker(svc.Scheduler))
}
// Notification routes
g.GET("/notifications/channels", listNotificationChannels(svc.Notification))
g.POST("/notifications/channels", createNotificationChannel(svc.Notification))
g.PUT("/notifications/channels/:id", updateNotificationChannel(svc.Notification))
g.DELETE("/notifications/channels/:id", deleteNotificationChannel(svc.Notification))
g.POST("/notifications/channels/:id/test", testNotificationChannel(svc.Notification))
g.GET("/notifications/queue", listNotificationQueue(svc.Notification))
// Discover routes
if svc.Discover != nil {
g.GET("/discover/trending", listTrending(svc.Discover))
g.GET("/discover/popular", listPopular(svc.Discover))
g.POST("/discover/add", addFromDiscover(svc.Discover))
}
// Calendar route
g.GET("/calendar", listCalendarEvents(svc.Calendar))
// Semantic search route
if svc.SemanticSearch != nil {
g.GET("/search/semantic", semanticSearch(svc.SemanticSearch))
}
// Request routes — protected by API key auth
apiKeyAuth := newAPIKeyAuth(svc.User)
g.GET("/requests", listRequests(svc.Request, svc.User), apiKeyAuth)
g.POST("/requests", createRequest(svc.Request, svc.User), apiKeyAuth)
g.GET("/requests/stats", requestStats(svc.Request, svc.User), apiKeyAuth)
g.PUT("/requests/:id/approve", approveRequest(svc.Request, svc.User), apiKeyAuth)
g.PUT("/requests/:id/reject", rejectRequest(svc.Request, svc.User), apiKeyAuth)
g.DELETE("/requests/:id", withdrawRequest(svc.Request, svc.User), apiKeyAuth)
return e
}
type paginatedResponse struct {
Data interface{} `json:"data"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
func newAPIKeyAuth(userSvc *service.UserService) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
key := c.Request().Header.Get("X-API-Key")
if key == "" {
key = c.QueryParam("api_key")
}
if key == "" {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "API key required"})
}
user, err := userSvc.GetUserByAPIKey(c.Request().Context(), key)
if err != nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid API key"})
}
c.Set("user", user)
return next(c)
}
}
}
func cacheControlMiddleware() echo.MiddlewareFunc {
shortCache := map[string]bool{
"/api/quality-profiles": true,
"/api/download-clients": true,
"/api/root-folders": true,
"/api/tags": true,
"/api/indexers": true,
"/api/workers": true,
}
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
err := next(c)
if err != nil {
return err
}
path := c.Request().URL.Path
if c.Request().Method == http.MethodGet {
if shortCache[path] {
c.Response().Header().Set("Cache-Control", "max-age=60")
} else if path == "/api/dashboard" {
c.Response().Header().Set("Cache-Control", "max-age=30")
} else if path == "/api/calendar" || path == "/api/activity" {
c.Response().Header().Set("Cache-Control", "max-age=15")
}
}
return nil
}
}
}