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
This commit is contained in:
+238
-21
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user