Files
bookhoard/SCANNER_RESTORATION_PLAN.md
T
john-okeefe 4b68ef8abd docs(scanner): add comprehensive scanner restoration and enhancement plan
- Document missing 10 scanner endpoints lost during router refactor
- Plan for library-type-aware scanner implementation
- Application lifecycle management via App pattern
- Background services auto-start (scheduler, watch mode)
- Graceful shutdown with signal handling
- Complete implementation guide with code snippets and testing checklist
- Safe phased approach with rollback procedures

Plan includes:
  - Phase 1: Create scanner routes file (internal/router/scanner.go)
  - Phase 2: Update router to capture EbookHandler
  - Phase 3: Implement library-type-aware scanning
  - Phase 4: Create application lifecycle management (internal/app/app.go)
  - Phase 5: Update main.go to use App pattern
  - Phase 6: Testing and verification

Ready for implementation in next session.
2026-02-06 21:59:30 -05:00

29 KiB

Scanner System Restoration & Enhancement Plan

Created: February 6, 2026 Context: Restore missing scanner functionality lost during router refactor, implement library-type-aware scanning Status: Ready to implement


Executive Summary

This plan restores the complete scanner system that was broken during the router refactor (commit 9bc8cd7), plus adds library-type-awareness to prevent cross-contamination between library types.

Changes:

  • 10 scanner endpoints restored
  • Library-type-aware scanner implementation
  • Background services auto-start (scheduler, watch mode)
  • Graceful shutdown with signal handling
  • Application lifecycle management via App struct

Files to Create:

  1. internal/router/scanner.go (new)
  2. internal/app/app.go (new)

Files to Modify:

  1. internal/router/router.go (add scanner route registration)
  2. cmd/server/main.go (use App pattern)
  3. internal/services/ebook_scanner.go (library-type-aware scanning)

Risk Assessment: LOW - All changes are additive or wrap existing code


Phase 1: Create Scanner Routes File

File: internal/router/scanner.go (NEW)

Purpose: Register all 10 scanner endpoints

Dependencies:

  • cfg.Config struct must have EbookHandler field
  • EbookHandler is returned from handlers.SetupRoutes()

Complete Code:

package router

import (
	"net/http"

	"github.com/labstack/echo/v4"
)

// registerScannerRoutes registers all scanner-related endpoints
func registerScannerRoutes(cfg *Config) {
	e := cfg.Echo

	// JWT middleware for protected routes
	jwtMiddleware := createJWTMiddleware(cfg)
	protected := e.Group("/api", jwtMiddleware)

	// User scan settings endpoints (existing in auth.go)
	protected.GET("/library/scan-settings", cfg.AuthHandler.GetScanSettings)
	protected.PUT("/library/scan-settings", cfg.AuthHandler.UpdateScanSettings)

	// Scanner control endpoints (ebook.go Handler)
	scanner := protected.Group("/scanner")
	scanner.POST("/scan", cfg.EbookHandler.ScanEbooks)
	scanner.GET("/status/:jobId", cfg.EbookHandler.GetScanStatus)
	scanner.POST("/start", cfg.EbookHandler.StartScanner)
	scanner.POST("/stop", cfg.EbookHandler.StopScanner)

	// Watch mode endpoints
	watch := protected.Group("/scanner/watch")
	watch.POST("/start", cfg.EbookHandler.StartWatchMode)
	watch.POST("/stop", cfg.EbookHandler.StopWatchMode)
	watch.GET("/status", cfg.EbookHandler.GetWatchModeStatus)
}

Verification:

  • 10 endpoints registered
  • All use cfg.EbookHandler methods
  • All protected by JWT middleware

Phase 2: Update Router to Call Scanner Routes

File: internal/router/router.go

Change 1: Capture EbookHandler from SetupRoutes

Location: Line 113-114

Current Code:

jwtMiddleware := createJWTMiddleware(cfg)
protected := e.Group("/api", jwtMiddleware)
handlers.SetupRoutes(protected, cfg.Queries, cfg.ConnManager)

// Register route groups

New Code:

jwtMiddleware := createJWTMiddleware(cfg)
protected := e.Group("/api", jwtMiddleware)
ebookHandler := handlers.SetupRoutes(protected, cfg.Queries, cfg.ConnManager)

// Register route groups

Change 2: Add EbookHandler to Config struct

Location: Line 33-52 (Config struct definition)

Add to struct:

type Config struct {
	Echo                 *echo.Echo
	Queries              *database.Queries
	Cfg                  *config.Config
	DBPool               interface{} // pgxpool.Pool interface
	AuthHandler          *handlers.AuthHandler
	LibraryHandler       *handlers.LibraryHandler
	DeviceHandler        *handlers.DeviceHandler
	EbookHandler         *handlers.Handler  // ← ADD THIS LINE
	KOReaderHandler      *handlers.KOReaderHandler
	WSHandler            *handlers.WSHandler
	ConflictHandler      *handlers.ConflictHandler
	AnalyticsHandler     *handlers.AnalyticsHandler
	QueueHandler         *handlers.QueueHandler
	CollectionHandler    *handlers.CollectionHandler
	OPDSHandler          *handlers.OPDSHandler
	ConnManager          *sync.ConnectionManager
	QueueProcessor       *sync.SyncQueueProcessor
	DeviceAuthMiddleware *middleware.DeviceAuthMiddleware
	LoginTracker         *ratelimit.LoginAttemptTracker
}

Change 3: Add scanner route registration

Location: Line 124 (after registerAnalyticsRoutes)

Current Code:

registerAnalyticsRoutes(cfg)
registerQueueRoutes(cfg)
registerOPDSRoutes(cfg)

New Code:

registerAnalyticsRoutes(cfg)
registerQueueRoutes(cfg)
registerScannerRoutes(cfg)  // ← ADD THIS LINE
registerOPDSRoutes(cfg)

Verification:

  • EbookHandler captured from SetupRoutes
  • Added to Config struct
  • registerScannerRoutes called in RegisterRoutes

Phase 3: Implement Library-Type-Aware Scanner

File: internal/services/ebook_scanner.go

Change 1: Add libraryTypes cache field

Location: Line 59-65 (EbookScanner struct)

Current Code:

type EbookScanner struct {
	db               *database.Queries
	watcher          *fsnotify.Watcher
	folders          []string
	adminID          pgtype.UUID
	defaultLibraryID pgtype.UUID
}

New Code:

type EbookScanner struct {
	db               *database.Queries
	watcher          *fsnotify.Watcher
	folders          []string
	adminID          pgtype.UUID
	defaultLibraryID pgtype.UUID
	libraryTypes     map[string][]string  // folder -> allowed extensions cache
}

Change 2: Initialize libraryTypes in NewHandler

Location: Line 73-74 (in NewEbookScanner function)

Current Code:

func NewEbookScanner(db *database.Queries) *EbookScanner {
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		panic(fmt.Sprintf("Failed to create file watcher: %v", err))
	}

	return &EbookScanner{
		db:               db,
		watcher:          watcher,
		folders:          []string{},
		adminID:          pgtype.UUID{},
		defaultLibraryID: pgtype.UUID{Valid: false},
	}
}

New Code:

func NewEbookScanner(db *database.Queries) *EbookScanner {
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		panic(fmt.Sprintf("Failed to create file watcher: %v", err))
	}

	return &EbookScanner{
		db:               db,
		watcher:          watcher,
		folders:          []string{},
		adminID:          pgtype.UUID{},
		defaultLibraryID: pgtype.UUID{Valid: false},
		libraryTypes:     make(map[string][]string),  // ← ADD THIS
	}
}

Change 3: Build library types cache in SetFolders

Location: Line 86-109 (SetFolders function)

Current Code:

func (s *EbookScanner) SetFolders(folders []string) error {
	s.folders = folders

	// Remove old watch if exists
	if s.watcher != nil {
		s.watcher.Close()
	}

	// Create new watcher
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		return fmt.Errorf("failed to create watcher: %v", err)
	}
	s.watcher = watcher

	// Add all folders to watch
	for _, folder := range folders {
		if err := s.watcher.Add(folder); err != nil {
			fmt.Printf("Warning: failed to watch folder %s: %v\n", folder, err)
		}
	}

	return nil
}

New Code:

func (s *EbookScanner) SetFolders(folders []string) error {
	s.folders = folders

	// Remove old watch if exists
	if s.watcher != nil {
		s.watcher.Close()
	}

	// Create new watcher
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		return fmt.Errorf("failed to create watcher: %v", err)
	}
	s.watcher = watcher

	// Build cache of allowed extensions per folder
	s.libraryTypes = make(map[string][]string)
	ctx := context.Background()

	for _, folder := range folders {
		// Get library for this folder
		lib, err := s.db.GetLibraryByFolder(ctx, folder)
		if err != nil {
			fmt.Printf("Warning: failed to get library for folder %s: %v\n", folder, err)
			continue
		}

		// Get library type with allowed extensions
		libType, err := s.db.GetLibraryType(ctx, lib.LibraryTypeID)
		if err != nil {
			fmt.Printf("Warning: failed to get library type for %s: %v\n", folder, err)
			continue
		}

		// Cache allowed extensions for this folder
		s.libraryTypes[folder] = libType.AllowedExtensions
		fmt.Printf("Scanner: Folder %s allows extensions: %v\n", folder, libType.AllowedExtensions)
	}

	// Add all folders to watch
	for _, folder := range folders {
		if err := s.watcher.Add(folder); err != nil {
			fmt.Printf("Warning: failed to watch folder %s: %v\n", folder, err)
		}
	}

	return nil
}

Change 4: Replace isEbookFile with isScannableFile

Location: Line 168-177 (isEbookFile function)

Current Code:

func (s *EbookScanner) isEbookFile(path string) bool {
	ext := strings.ToLower(filepath.Ext(path))
	switch ext {
	case ".epub", ".pdf", ".mobi", ".azw3", ".fb2", ".txt":
		return true
	default:
		return false
	}
}

New Code:

// isScannableFile checks if a file should be scanned based on library type configuration
func (s *EbookScanner) isScannableFile(path string) bool {
	ext := strings.ToLower(filepath.Ext(path))

	// Find which folder this file belongs to
	var folder string
	for _, f := range s.folders {
		if strings.HasPrefix(path, f) {
			folder = f
			break
		}
	}

	// If no folder match, don't scan
	if folder == "" {
		return false
	}

	// Get allowed extensions for this folder's library
	allowed, ok := s.libraryTypes[folder]
	if !ok {
		// No library type info, skip file
		fmt.Printf("Warning: No library type info for folder %s, skipping %s\n", folder, path)
		return false
	}

	// Check if file extension is allowed for this library type
	for _, allowedExt := range allowed {
		if ext == strings.ToLower(allowedExt) {
			return true
		}
	}

	return false
}

Change 5: Update ScanFolders to use isScannableFile

Location: Line 146 (in ScanFolders function)

Current Code:

// Check if it's an ebook file
if s.isEbookFile(path) {

New Code:

// Check if file should be scanned based on library type
if s.isScannableFile(path) {

Verification:

  • libraryTypes field added to struct
  • Initialized in NewEbookScanner
  • Populated in SetFolders from database
  • isScannableFile checks against library's allowed list
  • Prevents cross-contamination between library types

Phase 4: Create Application Lifecycle Management

File: internal/app/app.go (NEW)

Purpose: Manage application lifecycle, start/stop background services

Complete Code:

package app

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/labstack/echo/v4"
)

// App represents the application with all its components
type App struct {
	Echo          *echo.Echo
	EbookHandler  interface{} // *handlers.Handler from handlers/ebook.go
	Config        interface{} // *config.Config
	DBPool        interface{} // pgxpool.Pool
	shutdownFuncs []func() error
}

// New creates a new application instance
func New(echo *echo.Echo, ebookHandler interface{}, cfg interface{}, dbPool interface{}) *App {
	return &App{
		Echo:          echo,
		EbookHandler:  ebookHandler,
		Config:        cfg,
		DBPool:        dbPool,
		shutdownFuncs: []func() error{},
	}
}

// Start begins the application lifecycle
func (a *App) Start() error {
	log.Println("Starting application...")

	// Start background services
	if err := a.startBackgroundServices(); err != nil {
		return fmt.Errorf("failed to start background services: %w", err)
	}

	// Start HTTP server (blocking)
	addr := a.Echo.Addr(":8080") // Will be overridden by Echo
	if err := a.Echo.Start(addr); err != nil && err != http.ErrServerClosed {
		return fmt.Errorf("failed to start server: %w", err)
	}

	return nil
}

// Stop gracefully shuts down the application
func (a *App) Stop() {
	log.Println("Shutting down application...")

	// Stop background services in reverse order
	for i := len(a.shutdownFuncs) - 1; i >= 0; i-- {
		if fn := a.shutdownFuncs[i]; fn != nil {
			if err := fn(); err != nil {
				log.Printf("Error during shutdown: %v", err)
			}
		}
	}

	// Give HTTP server time to finish in-flight requests
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	if err := a.Echo.Shutdown(ctx); err != nil {
		log.Printf("Error during server shutdown: %v", err)
	}

	log.Println("Application stopped")
}

// startBackgroundServices initializes all background services
func (a *App) startBackgroundServices() error {
	log.Println("Starting background services...")

	// Start scheduler for auto-scanning
	// Note: EbookHandler has StartScheduler() method
	if err := a.callHandlerMethod("StartScheduler"); err != nil {
		log.Printf("Warning: Failed to start scheduler: %v", err)
	}

	// Start watch mode for all libraries (with delay)
	a.shutdownFuncs = append(a.shutdownFuncs, func() error {
		// Stop watch mode and scheduler on shutdown
		a.callHandlerMethod("StopScheduler")
		return nil
	})

	// Start watch mode in background after delay
	go func() {
		time.Sleep(2 * time.Second) // Wait for server to be ready
		if err := a.callHandlerMethod("StartWatchModeForAllLibraries", context.Background()); err != nil {
			log.Printf("Warning: Failed to start watch mode: %v", err)
		}
	}()

	return nil
}

// callHandlerMethod calls a method on the EbookHandler by name
func (a *App) callHandlerMethod(methodName string, args ...interface{}) error {
	// Use reflection or type assertion to call methods
	// For now, we'll need to type assert to access methods
	// This is a simplified version - in production, use proper reflection
	handler, ok := a.EbookHandler.(interface {
		StartScheduler()
		StopScheduler()
		StartWatchModeForAllLibraries(ctx context.Context) error
	})
	if !ok {
		return fmt.Errorf("handler does not support method: %s", methodName)
	}

	switch methodName {
	case "StartScheduler":
		handler.StartScheduler()
	case "StopScheduler":
		handler.StopScheduler()
	case "StartWatchModeForAllLibraries":
		if len(args) > 0 {
			if ctx, ok := args[0].(context.Context); ok {
				return handler.StartWatchModeForAllLibraries(ctx)
			}
		}
	}

	return nil
}

// WaitForShutdown blocks until a termination signal is received
func (a *App) WaitForShutdown() {
	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

	// Wait for signal
	sig := <-sigChan
	log.Printf("Received signal: %v", sig)

	// Initiate graceful shutdown
	a.Stop()
}

Note: The app.go file uses type assertions to call Handler methods. We need to ensure the Handler type is properly exposed or we'll need to use reflection. An alternative is to define an interface.

Alternative: Define Service Interface

Add this to app.go before the App struct:

// ScannerService defines the interface for scanner background services
type ScannerService interface {
	StartScheduler()
	StopScheduler()
	StartWatchModeForAllLibraries(ctx context.Context) error
}

Then change the App struct:

type App struct {
	Echo          *echo.Echo
	ScannerService ScannerService  // Use interface instead of interface{}
	Config        interface{}
	DBPool        interface{}
	shutdownFuncs []func() error
}

// In New():
func New(echo *echo.Echo, scanner ScannerService, ...) *App {
	return &App{
		ScannerService: scanner,
		// ...
	}
}

Verification:

  • App struct created
  • Start() calls background services
  • Stop() performs graceful shutdown
  • Signal handling implemented
  • No existing functionality broken

Phase 5: Update main.go to Use App Pattern

File: cmd/server/main.go

Current Code (Lines 64-163):

func main() {
	cfg := config.LoadConfig()

	dbPool, err := pgxpool.New(context.Background(), cfg.DatabaseURL())
	if err != nil {
		log.Fatal("Failed to connect to database:", err)
	}
	defer dbPool.Close()

	queries := database.New(dbPool)

	// Create login attempt tracker: 5 failed attempts = 15 minute lockout
	loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)

	authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
	libraryHandler := handlers.NewLibraryHandler(queries)
	deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
	deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)

	// Create WebSocket connection manager
	connManager := sync.NewConnectionManager()
	connManager.StartCleanupTask()

	// Create sync queue processor
	queueProcessor := sync.NewSyncQueueProcessor(queries)
	go queueProcessor.Start(context.Background())

	koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
	wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
	conflictHandler := handlers.NewConflictHandler(queries, connManager)
	analyticsHandler := handlers.NewAnalyticsHandler(queries)
	queueHandler := handlers.NewQueueHandler(queries, queueProcessor)

	// Create conversion service for EPUB→KEPUB conversion
	conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
	opdsHandler := handlers.NewOPDSHandler(queries, conversionService)

	e := echo.New()

	// Set up validator
	v := validator.New()

	// Register custom password complexity validator
	if err := ratelimit.RegisterPasswordValidation(v); err != nil {
		log.Fatal("Failed to register password validator:", err)
	}
	e.Validator = &CustomValidator{validator: v}

	// Middleware
	e.Use(echomiddleware.Logger())
	e.Use(echomiddleware.Recover())
	e.Use(echomiddleware.CORS())
	e.Use(ratelimit.RequestTracingMiddleware(cfg))

	// ========================================================================
	// ROUTER REGISTRATION - Migrate routes to internal/router/ package
	// ========================================================================
	routerConfig := &router.Config{
		Echo:                 e,
		Queries:              queries,
		Cfg:                  cfg,
		DBPool:               dbPool,
		AuthHandler:          authHandler,
		LibraryHandler:       libraryHandler,
		DeviceHandler:        deviceHandler,
		KOReaderHandler:      koreaderHandler,
		WSHandler:            wsHandler,
		ConflictHandler:      conflictHandler,
		AnalyticsHandler:     analyticsHandler,
		QueueHandler:         queueHandler,
		CollectionHandler:    nil, // TODO: Initialize collection handler
		OPDSHandler:          opdsHandler,
		ConnManager:          connManager,
		QueueProcessor:       queueProcessor,
		DeviceAuthMiddleware: deviceAuthMiddleware,
		LoginTracker:         loginAttemptTracker,
	}
	router.RegisterRoutes(routerConfig)

	// Public library types endpoint (no authentication required)
	e.GET("/api/libraries/types", libraryHandler.GetLibraryTypes)

	// ========================================================================
	// FRONTEND ROUTES, HEALTH CHECK, DOCS (all now in router package)
	// ========================================================================

	// Start server
	log.Printf("Starting server on port %s", cfg.ServerPort)
	e.Logger.Fatal(e.Start(":" + cfg.ServerPort))
}

New Code:

func main() {
	cfg := config.LoadConfig()

	dbPool, err := pgxpool.New(context.Background(), cfg.DatabaseURL())
	if err != nil {
		log.Fatal("Failed to connect to database:", err)
	}

	queries := database.New(dbPool)

	// Create login attempt tracker: 5 failed attempts = 15 minute lockout
	loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)

	authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
	libraryHandler := handlers.NewLibraryHandler(queries)
	deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
	deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)

	// Create WebSocket connection manager
	connManager := sync.NewConnectionManager()
	connManager.StartCleanupTask()

	// Create sync queue processor
	queueProcessor := sync.NewSyncQueueProcessor(queries)
	go queueProcessor.Start(context.Background())

	koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
	wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
	conflictHandler := handlers.NewConflictHandler(queries, connManager)
	analyticsHandler := handlers.NewAnalyticsHandler(queries)
	queueHandler := handlers.NewQueueHandler(queries, queueProcessor)

	// Create conversion service for EPUB→KEPUB conversion
	conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
	opdsHandler := handlers.NewOPDSHandler(queries, conversionService)

	e := echo.New()

	// Set up validator
	v := validator.New()

	// Register custom password complexity validator
	if err := ratelimit.RegisterPasswordValidation(v); err != nil {
		log.Fatal("Failed to register password validator:", err)
	}
	e.Validator = &CustomValidator{validator: v}

	// Middleware
	e.Use(echomiddleware.Logger())
	e.Use(echomiddleware.Recover())
	e.Use(echomiddleware.CORS())
	e.Use(ratelimit.RequestTracingMiddleware(cfg))

	// ========================================================================
	// ROUTER REGISTRATION - Migrate routes to internal/router/ package
	// ========================================================================
	routerConfig := &router.Config{
		Echo:                 e,
		Queries:              queries,
		Cfg:                  cfg,
		DBPool:               dbPool,
		AuthHandler:          authHandler,
		LibraryHandler:       libraryHandler,
		DeviceHandler:        deviceHandler,
		KOReaderHandler:      koreaderHandler,
		WSHandler:            wsHandler,
		ConflictHandler:      conflictHandler,
		AnalyticsHandler:     analyticsHandler,
		QueueHandler:         queueHandler,
		CollectionHandler:    nil, // TODO: Initialize collection handler
		OPDSHandler:          opdsHandler,
		ConnManager:          connManager,
		QueueProcessor:       queueProcessor,
		DeviceAuthMiddleware: deviceAuthMiddleware,
		LoginTracker:         loginAttemptTracker,
	}
	
	// Register all routes and get ebook handler
	ebookHandler := router.RegisterRoutes(routerConfig)

	// ========================================================================
	// APPLICATION LIFECYCLE MANAGEMENT
	// ========================================================================
	application := app.New(e, ebookHandler, cfg, dbPool)

	// Start application in background
	go func() {
		if err := application.Start(); err != nil {
			log.Fatal("Application error:", err)
		}
	}()

	// Wait for shutdown signal
	application.WaitForShutdown()
}

Key Changes:

  1. Capture ebookHandler return value from RegisterRoutes
  2. Create app.Application instance
  3. Start app in background goroutine
  4. Call WaitForShutdown() to block
  5. Graceful shutdown handled by app

Verification:

  • main.go reduced to setup code
  • Application lifecycle managed by App
  • Signal handling for graceful shutdown
  • Background services auto-start

Phase 6: Fix Import in App File

File: internal/app/app.go

Add to imports:

package app

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/labstack/echo/v4"
	"bookhoard/internal/handlers"  // ← ADD THIS
)

Then update the App struct to use concrete type:

type App struct {
	Echo          *echo.Echo
	EbookHandler  *handlers.Handler  // ← CHANGE from interface{} to concrete type
	Config        *config.Config        // ← CHANGE from interface{} to concrete type
	DBPool        *pgxpool.Pool         // ← CHANGE from interface{} to concrete type
	shutdownFuncs []func() error
}

Add imports:

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/labstack/echo/v4"
	"bookhoard/internal/config"    // ← ADD THIS
	"bookhoard/internal/handlers" // ← ADD THIS
)

Update New function signature:

func New(echo *echo.Echo, ebookHandler *handlers.Handler, cfg *config.Config, dbPool *pgxpool.Pool) *App {

Update startBackgroundServices to use concrete type:

func (a *App) startBackgroundServices() error {
	log.Println("Starting background services...")

	// Start scheduler for auto-scanning
	a.EbookHandler.StartScheduler()

	// Add shutdown function for scheduler
	a.shutdownFuncs = append(a.shutdownFuncs, func() error {
		a.EbookHandler.StopScheduler()
		return nil
	})

	// Start watch mode for all libraries (with delay)
	go func() {
		time.Sleep(2 * time.Second) // Wait for server to be ready
		if err := a.EbookHandler.StartWatchModeForAllLibraries(context.Background()); err != nil {
			log.Printf("Warning: Failed to start watch mode: %v", err)
		}
	}()

	return nil
}

Remove the callHandlerMethod function - no longer needed with concrete types.

Verification:

  • All imports added
  • Concrete types used instead of interface{}
  • Direct method calls to EbookHandler
  • Simpler, more maintainable code

Implementation Order (Step-by-Step)

Step 1: Create scanner routes (ADDITIVE ONLY)

  • Create internal/router/scanner.go
  • Zero risk - new file
  • Does not affect existing code

Step 2: Update router (MINIMAL CHANGES)

  • Capture EbookHandler in router.go
  • Add to Config struct
  • Call registerScannerRoutes
  • Low risk - only adds route registration

Step 3: Implement library-type-aware scanner (ENHANCEMENT)

  • Add libraryTypes field to EbookScanner
  • Update SetFolders to build cache
  • Replace isEbookFile with isScannableFile
  • Medium risk - core scanner logic change
  • TEST: Verify ebooks still scan correctly

Step 4: Create app package (ADDITIVE ONLY)

  • Create internal/app/app.go
  • Zero risk - new file
  • Does not affect existing code

Step 5: Update main.go (REFACTOR)

  • Replace initialization with App pattern
  • Add signal handling
  • Low risk - wraps existing code

Step 6: Test and Verify

  • Run all existing routes
  • Test new scanner endpoints
  • Verify library type filtering
  • Test graceful shutdown

Testing Checklist

After Each Phase:

Phase 1 (scanner.go created):

  • File created successfully
  • No compilation errors

Phase 2 (router updated):

  • Code compiles
  • All existing routes still accessible
  • New scanner endpoints return 401 (auth required) or 404 (not implemented handlers)

Phase 3 (library-type-aware scanner):

  • Code compiles
  • Ebook libraries scan ebooks only
  • Comic/manga libraries would scan appropriate files
  • No cross-contamination

Phase 4 (app package created):

  • File created successfully
  • No compilation errors

Phase 5 (main.go updated):

  • Application starts successfully
  • All existing routes work
  • Server responds to requests
  • Background services start (check logs)

Phase 6 (full system test):

  • Scanner endpoints accessible via curl
  • Can trigger manual scan
  • Scheduler starts (check logs)
  • Watch mode starts (check logs after 2 seconds)
  • SIGTERM/SIGINT triggers graceful shutdown
  • All existing functionality still works

Verification Commands

Test existing routes still work:

# Test authentication
curl -X POST http://localhost:8765/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"login":"testuser@example.com","password":"Test@Pass123!"}'

# Test libraries
TOKEN=<from login response>
curl http://localhost:8765/api/libraries/types
curl http://localhost:8765/api/libraries -H "Authorization: Bearer $TOKEN"

Test new scanner endpoints:

# Test scan settings
curl http://localhost:8765/api/library/scan-settings -H "Authorization: Bearer $TOKEN"

# Test scanner status (will return 404 if job doesn't exist)
curl http://localhost:8765/api/scanner/status/test-job-id -H "Authorization: Bearer $TOKEN"

# Test watch mode status
curl http://localhost:8765/api/scanner/watch/status -H "Authorization: Bearer $TOKEN"

Check logs for background services:

# Look for these log messages:
# "Starting scheduler for auto-scanning"
# "Scheduled scan for library"
# "Starting watch mode for all libraries"
# "Warning: failed to start watch mode"

Test graceful shutdown:

# Start server, then send SIGTERM
kill -TERM <pid>

# Or Ctrl+C which sends SIGINT
# Should see "Shutting down application..." message

Rollback Plan

If any phase breaks functionality:

Rollback Phase 2 (router changes):

git checkout internal/router/router.go

Rollback Phase 3 (scanner changes):

git checkout internal/services/ebook_scanner.go

Rollback Phase 5 (main.go changes):

git checkout cmd/server/main.go

Rollback new files:

rm internal/router/scanner.go
rm internal/app/app.go

Post-Implementation Improvements (Optional)

These are NOT part of this plan but could be future enhancements:

  1. Comic metadata extraction

    • Parse ComicInfo.xml from .cbz files
    • Extract cover images from comic archives
    • This is a separate feature
  2. Scanner metrics

    • Track scan duration
    • Count files per library type
    • Error rates by file type
  3. Scanner API improvements

    • Real-time scan progress via WebSocket
    • Scan history/audit log
    • Per-library scan schedules
  4. Rename scanner

    • EbookScanner → MediaScanner or LibraryScanner
    • Low priority, name doesn't affect functionality

Summary

What this restores:

  • 10 scanner endpoints (scan settings, control, watch mode)
  • Background scheduler for auto-scanning
  • Watch mode for instant ebook detection
  • Library-type-aware scanning (prevents cross-contamination)
  • Graceful shutdown with signal handling
  • Application lifecycle management

What this doesn't break:

  • All existing routes (auth, library, device, media, collections, etc.)
  • All existing API endpoints
  • Database schema
  • Any other functionality

Estimated implementation time: 2-3 hours Risk level: LOW Dependencies: None (uses existing code)

Ready for implementation in next session.