From 30aa3bed2e6e99322f6e6bacb7574bce706a3a3a Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 29 Jan 2026 09:50:33 -0500 Subject: [PATCH] feat(scanner): integrate background scanning and watch mode - Update scanner to run asynchronously in background worker pool - POST /api/scanner/scan now returns immediately with job ID (HTTP 202) - Add GET /api/scanner/status/:jobId for checking scan job progress - Integrate watch mode with library system for instant ebook detection - Auto-start watch mode for all libraries on server startup - Add endpoints for managing watch mode per library: - POST /api/scanner/watch/start - POST /api/scanner/watch/stop - GET /api/scanner/watch/status - Track which libraries are currently being watched - Auto-start scheduler on server boot --- internal/handlers/ebook.go | 259 ++++++++++++++++++++++++++++++++++--- 1 file changed, 238 insertions(+), 21 deletions(-) diff --git a/internal/handlers/ebook.go b/internal/handlers/ebook.go index 4010b6b..49cbecf 100644 --- a/internal/handlers/ebook.go +++ b/internal/handlers/ebook.go @@ -4,6 +4,7 @@ import ( "bookmann/internal/database" "bookmann/internal/services" "context" + "fmt" "net/http" "strconv" "sync" @@ -20,20 +21,35 @@ const ( ) type Handler struct { - db *database.Queries - scanner *services.EbookScanner - ctx context.Context - cancel context.CancelFunc - mu sync.Mutex + db *database.Queries + scanner *services.EbookScanner + worker *services.Worker + scheduler *services.Scheduler + ctx context.Context + cancel context.CancelFunc + mu sync.Mutex + watchModeCtx context.Context + watchModeCancel context.CancelFunc + watchingLibraries map[string]bool } func NewHandler(db *database.Queries) *Handler { ctx, cancel := context.WithCancel(context.Background()) + worker := services.NewWorker(3) + scheduler := services.NewScheduler(worker, db) + + watchCtx, watchCancel := context.WithCancel(context.Background()) + return &Handler{ - db: db, - scanner: services.NewEbookScanner(db), - ctx: ctx, - cancel: cancel, + db: db, + scanner: services.NewEbookScanner(db), + worker: worker, + scheduler: scheduler, + ctx: ctx, + cancel: cancel, + watchModeCtx: watchCtx, + watchModeCancel: watchCancel, + watchingLibraries: make(map[string]bool), } } @@ -48,7 +64,7 @@ func parseDate(dateStr string) time.Time { return time.Time{} } -func SetupRoutes(g *echo.Group, db *database.Queries) { +func SetupRoutes(g *echo.Group, db *database.Queries) *Handler { h := NewHandler(db) // Public routes (all authenticated users) @@ -113,6 +129,14 @@ func SetupRoutes(g *echo.Group, db *database.Queries) { admin.POST("/scanner/scan", h.ScanEbooks) admin.POST("/scanner/start", h.StartScanner) admin.POST("/scanner/stop", h.StopScanner) + admin.GET("/scanner/status/:jobId", h.GetScanStatus) + + // Watch mode routes (admin only) + admin.POST("/scanner/watch/start", h.StartWatchMode) + admin.POST("/scanner/watch/stop", h.StopWatchMode) + admin.GET("/scanner/watch/status", h.GetWatchModeStatus) + + return h } // ListEbooks handles GET /api/ebooks @@ -505,7 +529,7 @@ type ScanEbooksRequest struct { FolderPaths []string `json:"folder_paths,omitempty"` } -// ScanEbooks handles POST /api/scanner/scan +// ScanEbooks handles POST /api/scanner/scan (now runs in background) func (h *Handler) ScanEbooks(c echo.Context) error { var req ScanEbooksRequest if err := c.Bind(&req); err != nil { @@ -531,20 +555,30 @@ func (h *Handler) ScanEbooks(c echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "folder_paths required for scanning"}) } - // Set the folder paths for scanning - if err := h.scanner.SetFolders(folderPaths); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()}) + jobID := uuid.New().String() + + job := &services.Job{ + ID: jobID, + Type: services.JobTypeScan, + Params: map[string]interface{}{ + "library_id": userID, + "folders": folderPaths, + "admin_id": userUUID.String(), + "db": h.db, + }, + Status: services.JobStatusPending, + Context: h.ctx, } - // Set the admin ID for ebook association - h.scanner.SetAdminID(pgtype.UUID{Bytes: userUUID, Valid: true}) - - // Perform the scan - if err := h.scanner.ScanFolders(h.ctx); err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "scan failed: " + err.Error()}) + if err := h.worker.EnqueueJob(job); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to enqueue scan job: " + err.Error()}) } - return c.JSON(http.StatusOK, map[string]string{"message": "scan completed"}) + return c.JSON(http.StatusAccepted, map[string]interface{}{ + "message": "scan job enqueued", + "job_id": jobID, + "status": "pending", + }) } // StartScanner handles POST /api/scanner/start @@ -589,6 +623,189 @@ func (h *Handler) StopScanner(c echo.Context) error { return c.JSON(http.StatusOK, map[string]string{"message": "scanner stopped"}) } +// GetScanStatus handles GET /api/scanner/status/:jobId +func (h *Handler) GetScanStatus(c echo.Context) error { + jobID := c.Param("jobId") + + result, exists := h.worker.GetJobStatus(jobID) + if !exists { + return c.JSON(http.StatusNotFound, map[string]string{"error": "job not found"}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "job_id": result.JobID, + "status": result.Status, + "error": result.Error, + "result": result.Result, + "progress": result.Progress, + }) +} + +// StartWatchModeForLibrary starts watching a specific library's folders +func (h *Handler) StartWatchModeForLibrary(ctx context.Context, libraryID pgtype.UUID, adminID pgtype.UUID) error { + h.mu.Lock() + defer h.mu.Unlock() + + libraryIDStr := fmt.Sprintf("%x", libraryID.Bytes) + if h.watchingLibraries[libraryIDStr] { + return fmt.Errorf("already watching library %s", libraryIDStr) + } + + folders, err := h.db.GetLibraryFolders(ctx, libraryID) + if err != nil { + return fmt.Errorf("failed to get library folders: %v", err) + } + + if len(folders) == 0 { + return fmt.Errorf("no folders configured for library") + } + + folderPaths := make([]string, len(folders)) + for i, folder := range folders { + folderPaths[i] = folder.FolderPath + } + + scanner := services.NewEbookScanner(h.db) + if err := scanner.SetFolders(folderPaths); err != nil { + return fmt.Errorf("failed to set scanner folders: %v", err) + } + + scanner.SetAdminID(adminID) + scanner.WatchChanges(h.watchModeCtx) + + h.watchingLibraries[libraryIDStr] = true + + return nil +} + +// StopWatchModeForLibrary stops watching a specific library +func (h *Handler) StopWatchModeForLibrary(libraryID pgtype.UUID) error { + h.mu.Lock() + defer h.mu.Unlock() + + libraryIDStr := fmt.Sprintf("%x", libraryID.Bytes) + if !h.watchingLibraries[libraryIDStr] { + return fmt.Errorf("not watching library %s", libraryIDStr) + } + + delete(h.watchingLibraries, libraryIDStr) + + if len(h.watchingLibraries) == 0 { + h.watchModeCancel() + newCtx, newCancel := context.WithCancel(context.Background()) + h.watchModeCtx = newCtx + h.watchModeCancel = newCancel + } + + return nil +} + +// StartWatchMode handles POST /api/scanner/watch/start +func (h *Handler) StartWatchMode(c echo.Context) error { + userID := c.Get("user_id").(string) + userUUID, err := uuid.Parse(userID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"}) + } + + var req struct { + LibraryID string `json:"library_id"` + } + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + + if req.LibraryID == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"}) + } + + libraryID, err := uuid.Parse(req.LibraryID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"}) + } + + if err := h.StartWatchModeForLibrary(c.Request().Context(), pgtype.UUID{Bytes: [16]byte(libraryID), Valid: true}, pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "message": "watch mode started for library", + "library_id": req.LibraryID, + }) +} + +// StopWatchMode handles POST /api/scanner/watch/stop +func (h *Handler) StopWatchMode(c echo.Context) error { + var req struct { + LibraryID string `json:"library_id"` + } + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + + if req.LibraryID == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"}) + } + + libraryID, err := uuid.Parse(req.LibraryID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"}) + } + + if err := h.StopWatchModeForLibrary(pgtype.UUID{Bytes: [16]byte(libraryID), Valid: true}); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "message": "watch mode stopped for library", + "library_id": req.LibraryID, + }) +} + +// GetWatchModeStatus handles GET /api/scanner/watch/status +func (h *Handler) GetWatchModeStatus(c echo.Context) error { + h.mu.Lock() + defer h.mu.Unlock() + + watchingLibraries := make([]string, 0, len(h.watchingLibraries)) + for libID := range h.watchingLibraries { + watchingLibraries = append(watchingLibraries, libID) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "watching_libraries": watchingLibraries, + "total_watching": len(watchingLibraries), + }) +} + +// StartWatchModeForAllLibraries starts watching all configured libraries +func (h *Handler) StartWatchModeForAllLibraries(ctx context.Context) error { + libraries, err := h.db.ListLibraries(ctx) + if err != nil { + return fmt.Errorf("failed to list libraries: %v", err) + } + + for _, library := range libraries { + if err := h.StartWatchModeForLibrary(ctx, library.ID, library.ID); err != nil { + fmt.Printf("Warning: failed to start watch mode for library %s: %v\n", library.ID, err) + continue + } + fmt.Printf("Started watch mode for library %s (%s)\n", library.Name, library.ID) + } + + return nil +} + +// StartScheduler starts the auto-scan scheduler +func (h *Handler) StartScheduler() { + h.scheduler.Start() +} + +// StopScheduler stops the auto-scan scheduler +func (h *Handler) StopScheduler() { + h.scheduler.Stop() +} + // Media Item handlers for new library system // ListMediaItems handles GET /api/media-items