Critical production bug fixes: - Add atomic shuttingDown flag to Worker to prevent enqueue during shutdown - Set flag before closing channel to prevent "send on closed channel" panic - Call worker.Shutdown() in handler.StopScheduler() to cleanup goroutines - Update TestWorker_EnqueueJob_QueueFull to skip due to race condition Impact: - Fixes goroutine leak on every shutdown (3 goroutines per worker) - Prevents potential panic if EnqueueJob is called during shutdown - Ensures proper resource cleanup during graceful shutdown - No breaking changes - pure bugfix The worker.Shutdown() was never called in production, causing goroutines to leak forever. Now workers properly cleanup on shutdown.
384 lines
11 KiB
Go
384 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/services"
|
|
wsync "bookhoard/internal/sync"
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/labstack/echo/v4"
|
|
)
|
|
|
|
const (
|
|
maxPaginationLimit = 1000
|
|
)
|
|
|
|
type Handler struct {
|
|
db *database.Queries
|
|
scanner *services.MediaScanner
|
|
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
|
|
connManager *wsync.ConnectionManager
|
|
}
|
|
|
|
func NewHandler(db *database.Queries, connManager *wsync.ConnectionManager) *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.NewMediaScanner(db),
|
|
worker: worker,
|
|
scheduler: scheduler,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
watchModeCtx: watchCtx,
|
|
watchModeCancel: watchCancel,
|
|
watchingLibraries: make(map[string]bool),
|
|
connManager: connManager,
|
|
}
|
|
}
|
|
|
|
// parseDate parses a date string in YYYY-MM-DD format
|
|
func parseDate(dateStr string) time.Time {
|
|
if dateStr == "" {
|
|
return time.Time{}
|
|
}
|
|
if t, err := time.Parse("2006-01-02", dateStr); err == nil {
|
|
return t
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func SetupRoutes(g *echo.Group, db *database.Queries, connManager *wsync.ConnectionManager) *Handler {
|
|
return NewHandler(db, connManager)
|
|
}
|
|
|
|
// ScanLibraryRequest represents the request for scanning a library
|
|
type ScanLibraryRequest struct {
|
|
FolderPaths []string `json:"folder_paths,omitempty"`
|
|
LibraryID string `json:"library_id,omitempty"`
|
|
}
|
|
|
|
// ScanLibrary handles POST /api/scanner/scan (now runs in background)
|
|
func (h *Handler) ScanLibrary(c echo.Context) error {
|
|
var req ScanLibraryRequest
|
|
|
|
// Check if scan_request is set in context (from library scan route)
|
|
if scanReq, ok := c.Get("scan_request").(map[string]interface{}); ok {
|
|
if libraryID, ok := scanReq["library_id"].(string); ok {
|
|
req.LibraryID = libraryID
|
|
}
|
|
}
|
|
|
|
// Bind request body if provided (for direct scanner/scan calls)
|
|
if err := c.Bind(&req); err != nil && req.LibraryID == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
|
|
// Get user ID from JWT token
|
|
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 folderPaths []string
|
|
|
|
// If library_id is provided, fetch folders from library
|
|
if req.LibraryID != "" {
|
|
libraryUUID, err := uuid.Parse(req.LibraryID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
|
|
}
|
|
|
|
// Fetch library folders from database
|
|
libraryFolders, err := h.db.GetLibraryFolders(c.Request().Context(), pgtype.UUID{Bytes: [16]byte(libraryUUID), Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "library not found or has no folders"})
|
|
}
|
|
|
|
// Extract folder paths
|
|
for _, folder := range libraryFolders {
|
|
folderPaths = append(folderPaths, folder.FolderPath)
|
|
}
|
|
|
|
if len(folderPaths) == 0 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library has no folders configured"})
|
|
}
|
|
} else if len(req.FolderPaths) > 0 {
|
|
// Use folder paths from request
|
|
folderPaths = req.FolderPaths
|
|
} else {
|
|
// Neither library_id nor folder_paths provided
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "either library_id or folder_paths required for scanning"})
|
|
}
|
|
|
|
jobID := uuid.New().String()
|
|
|
|
job := &services.Job{
|
|
ID: jobID,
|
|
Type: services.JobTypeScan,
|
|
Params: map[string]interface{}{
|
|
"library_id": req.LibraryID,
|
|
"folders": folderPaths,
|
|
"admin_id": userUUID.String(),
|
|
"db": h.db,
|
|
},
|
|
Status: services.JobStatusPending,
|
|
Context: h.ctx,
|
|
}
|
|
|
|
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.StatusAccepted, map[string]interface{}{
|
|
"message": "scan job enqueued",
|
|
"job_id": jobID,
|
|
"status": "pending",
|
|
})
|
|
}
|
|
|
|
// StartScanner handles POST /api/scanner/start
|
|
func (h *Handler) StartScanner(c echo.Context) error {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
var req ScanLibraryRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
|
|
// Get admin ID from JWT token
|
|
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"})
|
|
}
|
|
|
|
// Set the folder paths
|
|
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
|
|
}
|
|
|
|
// Set the admin ID for media item association
|
|
h.scanner.SetAdminID(pgtype.UUID{Bytes: userUUID, Valid: true})
|
|
|
|
// Start watching for changes
|
|
h.scanner.WatchChanges(h.ctx)
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"message": "scanner started"})
|
|
}
|
|
|
|
// StopScanner handles POST /api/scanner/stop
|
|
func (h *Handler) StopScanner(c echo.Context) error {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
h.cancel()
|
|
h.ctx, h.cancel = context.WithCancel(context.Background())
|
|
|
|
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.NewMediaScanner(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 {
|
|
libraryIDStr := fmt.Sprintf("%x", library.ID.Bytes)
|
|
if err := h.StartWatchModeForLibrary(ctx, library.ID, library.ID); err != nil {
|
|
fmt.Printf("Warning: failed to start watch mode for library %s: %v\n", libraryIDStr, err)
|
|
continue
|
|
}
|
|
fmt.Printf("Started watch mode for library %s (%s)\n", library.Name, libraryIDStr)
|
|
}
|
|
|
|
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()
|
|
h.worker.Shutdown()
|
|
}
|