- 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
227 lines
8.4 KiB
Go
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
|
|
}
|
|
}
|
|
}
|