- 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.
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:
internal/router/scanner.go(new)internal/app/app.go(new)
Files to Modify:
internal/router/router.go(add scanner route registration)cmd/server/main.go(use App pattern)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.Configstruct must haveEbookHandlerfieldEbookHandleris returned fromhandlers.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:
- Capture ebookHandler return value from RegisterRoutes
- Create app.Application instance
- Start app in background goroutine
- Call WaitForShutdown() to block
- 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:
-
Comic metadata extraction
- Parse ComicInfo.xml from .cbz files
- Extract cover images from comic archives
- This is a separate feature
-
Scanner metrics
- Track scan duration
- Count files per library type
- Error rates by file type
-
Scanner API improvements
- Real-time scan progress via WebSocket
- Scan history/audit log
- Per-library scan schedules
-
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.