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 } 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)) // 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 } } }