diff --git a/EBOOK_REFACTOR_PLAN.md b/EBOOK_REFACTOR_PLAN.md new file mode 100644 index 0000000..7517f4e --- /dev/null +++ b/EBOOK_REFACTOR_PLAN.md @@ -0,0 +1,857 @@ +# Comprehensive ebook.go Refactor Plan + +**Created:** February 6, 2026 +**Purpose:** Refactor 1,350-line ebook.go into focused, single-responsibility files +**Assumption:** Scanner routes already moved to router/scanner.go +**Goal:** Zero API changes, 100% functional compatibility + +--- + +## Executive Summary + +`internal/handlers/ebook.go` violates Go single responsibility principle at 1,350 lines: +- **Scanner operations**: 296 lines (12 methods) +- **Media CRUD & metadata**: 822 lines (24 methods) +- **Book matching/sync**: ~150 lines (8 methods) +- **Collection device mappings**: 60+ lines (6 methods) + +**Proposed split:** +1. `media.go` - Media items, ratings, progress, notes, highlights (CRUD + metadata) +2. `search.go` - Query, search, filtered operations +3. `matching.go` - Book linking, file aliases, matching algorithms +4. `ebook.go` - Slimmed to only core Handler struct and SetupRoutes + +**Risk Level:** VERY LOW +- No API endpoint changes +- No database schema changes +- Only code organization +- All existing tests continue to work + +--- + +## File Analysis Before Refactor + +### Current ebook.go Structure (1,350 lines) + +```go +// Lines 38-164: Handler setup and SetupRoutes() +func NewHandler(...) *Handler { } +func SetupRoutes(...) *Handler { } + +// Lines 166-461: Scanner operations (12 methods) +func (h *Handler) ScanEbooks() { } +func (h *Handler) StartScanner() { } +// ... 10 more scanner methods + +// Lines 474-1296: Media operations (24 methods) +func (h *Handler) ListMediaItems() { } +func (h *Handler) GetMediaItem() { } +func (h *Handler) CreateMediaRating() { } +// ... 21 more media methods + +// Lines 1297-end: Search & matching (8 methods) +func (h *Handler) SearchMediaItems() { } +func (h *Handler) QueryBooks() { } +// ... 6 more methods +``` + +### Handler Dependencies + +All methods share this Handler struct: +```go +type Handler struct { + db *database.Queries + worker *worker.Worker + scanner *services.EbookScanner + scheduler *cron.Cron + ctx context.Context + cancel context.CancelFunc + mu sync.RWMutex + watchingLibraries map[string]bool +} +``` + +--- + +## Phase 1: SetupRoutes Restructure (FOUNDATION) + +### Purpose +Remove all route registration from SetupRoutes, make it a pure factory function + +### File: `internal/handlers/ebook.go` + +#### Step 1.1: Simplify SetupRoutes (Lines 70-157) + +**Current Code Problem:** +```go +func SetupRoutes(g *echo.Group, db *database.Queries, connManager *wsync.ConnectionManager) *Handler { + h := NewHandler(db, connManager) + + // Collections API routes (21 lines) + collectionHandler := NewCollectionHandler(db, connManager) + collections := g.Group("/collections") + collections.GET("", collectionHandler.GetCollections) + // ... 7 more collection routes + + // Device shelf mapping routes (6 lines) + deviceCollections := g.Group("/devices/:id/collections") + // ... 6 device routes + + // Book matching routes (4 lines) + g.POST("/sync/books/query", h.QueryBooks) + // ... 3 more matching routes + + // Media item routes (4 lines) + g.GET("/media-items", h.ListMediaItems) + // ... 3 more media routes + + // Admin-only routes (11 lines) + admin := g.Group("", AdminMiddleware) + admin.POST("/media-items", h.CreateMediaItem) + // ... 10 more admin routes (including scanner) + + return h +} +``` + +**New Code:** +```go +func SetupRoutes(g *echo.Group, db *database.Queries, connManager *wsync.ConnectionManager) *Handler { + return NewHandler(db, connManager) +} +``` + +**Verification:** +- ✅ SetupRoutes now only creates handler instance +- ✅ All routes will be registered via router package +- ✅ Handler struct and dependencies unchanged + +--- + +## Phase 2: Create Media Handler (CORE CRUD) + +### File: `internal/handlers/media.go` (NEW) + +#### Step 2.1: Create MediaHandler struct + +```go +package handlers + +import ( + "bookhoard/internal/database" + "bookhoard/internal/sync" + "context" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" +) + +// MediaHandler handles media item CRUD operations and metadata +type MediaHandler struct { + db *database.Queries + worker *worker.Worker +} + +// NewMediaHandler creates a new media handler +func NewMediaHandler(db *database.Queries, worker *worker.Worker) *MediaHandler { + return &MediaHandler{ + db: db, + worker: worker, + } +} +``` + +#### Step 2.2: Move media CRUD methods (From ebook.go lines 474-959) + +**Methods to move:** +- `ListMediaItems()` → `media.go:ListMediaItems()` +- `GetMediaItem()` → `media.go:GetMediaItem()` +- `ListMediaItemsFiltered()` → `media.go:ListMediaItemsFiltered()` +- `CreateMediaItem()` → `media.go:CreateMediaItem()` +- `UpdateMediaItem()` → `media.go:UpdateMediaItem()` +- `DeleteMediaItem()` → `media.go:DeleteMediaItem()` +- `SearchMediaItems()` → `media.go:SearchMediaItems()` + +**Method signature changes:** +```go +// OLD in ebook.go +func (h *Handler) ListMediaItems(c echo.Context) error + +// NEW in media.go +func (mh *MediaHandler) ListMediaItems(c echo.Context) error +``` + +**Implementation:** +Copy exact method bodies, change receiver from `h *Handler` to `mh *MediaHandler` + +#### Step 2.3: Move metadata methods (From ebook.go lines 630-839) + +**Methods to move:** +- `CreateMediaRating()` → `media.go:CreateMediaRating()` +- `GetMediaRating()` → `media.go:GetMediaRating()` +- `UpdateMediaRating()` → `media.go:UpdateMediaRating()` +- `DeleteMediaRating()` → `media.go:DeleteMediaRating()` + +- `GetMediaReadingProgress()` → `media.go:GetMediaReadingProgress()` +- `UpdateMediaReadingProgress()` → `media.go:UpdateMediaReadingProgress()` +- `DeleteMediaReadingProgress()` → `media.go:DeleteMediaReadingProgress()` + +- `GetMediaNotes()` → `media.go:GetMediaNotes()` +- `CreateMediaNote()` → `media.go:CreateMediaNote()` +- `GetMediaNote()` → `media.go:GetMediaNote()` +- `UpdateMediaNote()` → `media.go:UpdateMediaNote()` +- `DeleteMediaNote()` → `media.go:DeleteMediaNote()` + +- `GetMediaHighlights()` → `media.go:GetMediaHighlights()` +- `CreateMediaHighlight()` → `media.go:CreateMediaHighlight()` +- `GetMediaHighlight()` → `media.go:GetMediaHighlight()` +- `UpdateMediaHighlight()` → `media.go:UpdateMediaHighlight()` +- `DeleteMediaHighlight()` → `media.go:DeleteMediaHighlight()` + +**Verification after move:** +- [ ] All methods compile with new receiver type +- [ ] No changes to method bodies +- [ ] All database calls work (same `mh.db` reference) +- [ ] All imports are correct + +--- + +## Phase 3: Create Search Handler (QUERY OPERATIONS) + +### File: `internal/handlers/search.go` (NEW) + +#### Step 3.1: Create SearchHandler struct + +```go +package handlers + +import ( + "bookhoard/internal/database" + "context" + "fmt" + "net/http" + "strconv" + + "github.com/labstack/echo/v4" +) + +// SearchHandler handles search, query, and filtered operations +type SearchHandler struct { + db *database.Queries +} + +// NewSearchHandler creates a new search handler +func NewSearchHandler(db *database.Queries) *SearchHandler { + return &SearchHandler{ + db: db, + } +} +``` + +#### Step 3.2: Move search methods (From ebook.go lines 1297-end) + +**Methods to move:** +- `SearchMediaItems()` → `search.go:SearchMediaItems()` +- `QueryBooks()` → `search.go:QueryBooks()` + +**Note:** These methods are small and focused, perfect for separate handler + +--- + +## Phase 4: Create Matching Handler (SYNC OPERATIONS) + +### File: `internal/handlers/matching.go` (NEW) + +#### Step 4.1: Create MatchingHandler struct + +```go +package handlers + +import ( + "bookhoard/internal/database" + "bookhoard/internal/sync" + "context" + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" +) + +// MatchingHandler handles book matching, linking, and file alias operations +type MatchingHandler struct { + db *database.Queries + connManager *wsync.ConnectionManager +} + +// NewMatchingHandler creates a new matching handler +func NewMatchingHandler(db *database.Queries, connManager *wsync.ConnectionManager) *MatchingHandler { + return &MatchingHandler{ + db: db, + connManager: connManager, + } +} +``` + +#### Step 4.2: Move matching methods (From ebook.go lines 96-104) + +**Methods to move:** +- `QueryBooks()` → `matching.go:QueryBooks()` +- `LinkBook()` → `matching.go:LinkBook()` +- `GetUnlinkedBooks()` → `matching.go:GetUnlinkedBooks()` +- `GetDeviceFileAliases()` → `matching.go:GetDeviceFileAliases()` +- `CreateDeviceFileAlias()` → `matching.go:CreateDeviceFileAlias()` +- `UpdateDeviceFileAlias()` → `matching.go:UpdateDeviceFileAlias()` +- `DeleteDeviceFileAlias()` → `matching.go:DeleteDeviceFileAlias()` +- `GetBookMatches()` → `matching.go:GetBookMatches()` + +--- + +## Phase 5: Update Router Package (ROUTE REGISTRATION) + +### Purpose +Register all routes with new handler types, maintain exact same endpoints + +#### Step 5.1: Update router.go Config struct + +**File:** `internal/router/router.go` + +**Current Config (lines 33-52):** +```go +type Config struct { + Echo *echo.Echo + Queries *database.Queries + Cfg *config.Config + DBPool interface{} + AuthHandler *handlers.AuthHandler + LibraryHandler *handlers.LibraryHandler + DeviceHandler *handlers.DeviceHandler + EbookHandler *handlers.Handler // ← WILL BE REMOVED + 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 +} +``` + +**New Config (replace EbookHandler with 3 new handlers):** +```go +type Config struct { + Echo *echo.Echo + Queries *database.Queries + Cfg *config.Config + DBPool interface{} + AuthHandler *handlers.AuthHandler + LibraryHandler *handlers.LibraryHandler + DeviceHandler *handlers.DeviceHandler + // EbookHandler REMOVED - replaced below: + MediaHandler *handlers.MediaHandler // ← NEW + SearchHandler *handlers.SearchHandler // ← NEW + MatchingHandler *handlers.MatchingHandler // ← NEW + 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 +} +``` + +#### Step 5.2: Update RegisterRoutes function + +**File:** `internal/router/router.go` + +**Current route registration (lines 114-129):** +```go +jwtMiddleware := createJWTMiddleware(cfg) +protected := e.Group("/api", jwtMiddleware) +handlers.SetupRoutes(protected, cfg.Queries, cfg.ConnManager) // OLD METHOD + +// Register route groups +registerAuthRoutes(cfg, rateLimitMiddleware) +registerLibraryRoutes(cfg) +registerDeviceRoutes(cfg) +registerSyncRoutes(cfg) +registerMediaRoutes(cfg) +registerConflictRoutes(cfg) +registerAnalyticsRoutes(cfg) +registerQueueRoutes(cfg) +registerOPDSRoutes(cfg) +registerWebSocketRoutes(cfg) +registerFrontendRoutes(cfg) +registerDocumentationRoutes(cfg) +``` + +**New route registration:** +```go +jwtMiddleware := createJWTMiddleware(cfg) +protected := e.Group("/api", jwtMiddleware) + +// Create handler instances using factory functions +mediaHandler := handlers.NewMediaHandler(cfg.Queries, cfg.Worker) // Worker needed +searchHandler := handlers.NewSearchHandler(cfg.Queries) +matchingHandler := handlers.NewMatchingHandler(cfg.Queries, cfg.ConnManager) + +// Register route groups +registerAuthRoutes(cfg, rateLimitMiddleware) +registerLibraryRoutes(cfg) +registerDeviceRoutes(cfg) +registerSyncRoutes(cfg) +registerMediaRoutes(cfg) // ← WILL USE NEW MediaHandler +registerSearchRoutes(cfg) // ← NEW FILE +registerMatchingRoutes(cfg) // ← NEW FILE +registerConflictRoutes(cfg) +registerAnalyticsRoutes(cfg) +registerQueueRoutes(cfg) +registerOPDSRoutes(cfg) +registerWebSocketRoutes(cfg) +registerFrontendRoutes(cfg) +registerDocumentationRoutes(cfg) +``` + +#### Step 5.3: Create registerSearchRoutes function + +**File:** `internal/router/search.go` (NEW) + +```go +package router + +import ( + "net/http" + "github.com/labstack/echo/v4" +) + +func registerSearchRoutes(cfg *Config) { + e := cfg.Echo + + // JWT middleware for protected routes + jwtMiddleware := createJWTMiddleware(cfg) + protected := e.Group("/api", jwtMiddleware) + + // Search and query endpoints (all authenticated users) + protected.GET("/media-items/search", cfg.SearchHandler.SearchMediaItems) + protected.POST("/sync/books/query", cfg.MatchingHandler.QueryBooks) +} +``` + +#### Step 5.4: Create registerMatchingRoutes function + +**File:** `internal/router/matching.go` (NEW) + +```go +package router + +import ( + "github.com/labstack/echo/v4" +) + +func registerMatchingRoutes(cfg *Config) { + e := cfg.Echo + + // JWT middleware for protected routes + jwtMiddleware := createJWTMiddleware(cfg) + protected := e.Group("/api", jwtMiddleware) + + // Book matching and linking routes (all authenticated users) + protected.POST("/devices/:deviceId/sync/link-book", cfg.MatchingHandler.LinkBook) + protected.GET("/devices/:deviceId/sync/unlinked-books", cfg.MatchingHandler.GetUnlinkedBooks) + + // File alias routes (all authenticated users) + protected.GET("/devices/:id/file-aliases", cfg.MatchingHandler.GetDeviceFileAliases) + protected.POST("/devices/:id/file-aliases", cfg.MatchingHandler.CreateDeviceFileAlias) + protected.PUT("/devices/:id/file-aliases/:aliasId", cfg.MatchingHandler.UpdateDeviceFileAlias) + protected.DELETE("/devices/:id/file-aliases/:aliasId", cfg.MatchingHandler.DeleteDeviceFileAlias) + protected.GET("/books/match", cfg.MatchingHandler.GetBookMatches) +} +``` + +#### Step 5.5: Update existing registerMediaRoutes function + +**File:** `internal/router/media.go` (MODIFY) + +**Current:** Empty or minimal +**New:** Register all media CRUD endpoints + +```go +package router + +import ( + "github.com/labstack/echo/v4" + "bookhoard/internal/handlers" +) + +func registerMediaRoutes(cfg *Config) { + e := cfg.Echo + + // JWT middleware for protected routes + jwtMiddleware := createJWTMiddleware(cfg) + protected := e.Group("/api", jwtMiddleware) + admin := protected.Group("", handlers.AdminMiddleware) + + // Media item routes (all authenticated users) + protected.GET("/media-items", cfg.MediaHandler.ListMediaItems) + protected.GET("/media-items/filtered", cfg.MediaHandler.ListMediaItemsFiltered) + protected.GET("/media-items/search", cfg.MediaHandler.SearchMediaItems) + protected.GET("/media-items/:id", cfg.MediaHandler.GetMediaItem) + + // Media rating routes (all authenticated users) + protected.POST("/media-items/:id/rating", cfg.MediaHandler.CreateMediaRating) + protected.GET("/media-items/:id/rating", cfg.MediaHandler.GetMediaRating) + protected.PUT("/media-items/:id/rating", cfg.MediaHandler.UpdateMediaRating) + protected.DELETE("/media-items/:id/rating", cfg.MediaHandler.DeleteMediaRating) + + // Progress routes (all authenticated users) + protected.GET("/progress/:id", cfg.MediaHandler.GetUniversalProgress) + protected.POST("/progress/:id", cfg.MediaHandler.UpdateUniversalProgress) + protected.GET("/progress/:id/history", cfg.MediaHandler.GetProgressHistory) + + // Notes routes (all authenticated users) + protected.GET("/media-items/:id/notes", cfg.MediaHandler.GetMediaNotes) + protected.POST("/media-items/:id/notes", cfg.MediaHandler.CreateMediaNote) + protected.GET("/media-items/:id/notes/:noteId", cfg.MediaHandler.GetMediaNote) + protected.PUT("/media-items/:id/notes/:noteId", cfg.MediaHandler.UpdateMediaNote) + protected.DELETE("/media-items/:id/notes/:noteId", cfg.MediaHandler.DeleteMediaNote) + + // Highlights routes (all authenticated users) + protected.GET("/media-items/:id/highlights", cfg.MediaHandler.GetMediaHighlights) + protected.POST("/media-items/:id/highlights", cfg.MediaHandler.CreateMediaHighlight) + protected.GET("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.GetMediaHighlight) + protected.PUT("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.UpdateMediaHighlight) + protected.DELETE("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.DeleteMediaHighlight) + + // Admin-only media routes + admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem) + admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem) + admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem) +} +``` + +--- + +## Phase 6: Update main.go (INITIALIZATION) + +### File: `cmd/server/main.go` + +#### Step 6.1: Create new handler instances + +**Current handler creation (lines 78-99):** +```go +authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker) +libraryHandler := handlers.NewLibraryHandler(queries) +deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg) +// ... other handlers +``` + +**Add new handler instances:** +```go +authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker) +libraryHandler := handlers.NewLibraryHandler(queries) +deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg) +// ... existing handlers ... + +// NEW: Create refactored handlers +mediaHandler := handlers.NewMediaHandler(queries, queueProcessor) // Worker needed +searchHandler := handlers.NewSearchHandler(queries) +matchingHandler := handlers.NewMatchingHandler(queries, connManager) +``` + +#### Step 6.2: Update routerConfig + +**Current routerConfig (lines 131-150):** +```go +routerConfig := &router.Config{ + Echo: e, + Queries: queries, + Cfg: cfg, + DBPool: dbPool, + AuthHandler: authHandler, + LibraryHandler: libraryHandler, + DeviceHandler: deviceHandler, + EbookHandler: ebookHandler, // ← REMOVE + KOReaderHandler: koreaderHandler, + WSHandler: wsHandler, + // ... other fields +} +``` + +**New routerConfig:** +```go +routerConfig := &router.Config{ + Echo: e, + Queries: queries, + Cfg: cfg, + DBPool: dbPool, + AuthHandler: authHandler, + LibraryHandler: libraryHandler, + DeviceHandler: deviceHandler, + // EbookHandler REMOVED + MediaHandler: mediaHandler, // ← NEW + SearchHandler: searchHandler, // ← NEW + MatchingHandler: matchingHandler, // ← NEW + KOReaderHandler: koreaderHandler, + WSHandler: wsHandler, + ConflictHandler: conflictHandler, + AnalyticsHandler: analyticsHandler, + QueueHandler: queueHandler, + CollectionHandler: collectionHandler, + OPDSHandler: opdsHandler, + ConnManager: connManager, + QueueProcessor: queueProcessor, + DeviceAuthMiddleware: deviceAuthMiddleware, + LoginTracker: loginAttemptTracker, +} +``` + +**Note:** Need to determine where `worker` comes from for MediaHandler. Check existing code. + +--- + +## Phase 7: Clean Up ebook.go (FINALIZE) + +### File: `internal/handlers/ebook.go` + +#### Step 7.1: Remove moved methods + +**Remove these method sections:** +- Lines 474-959: All media CRUD and metadata methods +- Lines 1297-end: All search and matching methods +- Lines 73-104: Collection and device mapping routes +- Lines 105-143: All route registrations + +**Keep these sections:** +- Lines 38-69: Handler struct and NewHandler function +- Lines 70-72: Simplified SetupRoutes function +- Lines 159-end: Request/response structs + +#### Step 7.2: Update Handler struct dependencies + +**Current Handler struct (lines 19-30):** +```go +type Handler struct { + db *database.Queries + worker *worker.Worker // ← MOVE TO MediaHandler + scanner *services.EbookScanner // ← MAY MOVE TO ScannerHandler + scheduler *cron.Cron // ← MAY MOVE TO ScannerHandler + ctx context.Context + cancel context.CancelFunc + mu sync.RWMutex + watchingLibraries map[string]bool +} +``` + +**Potential final Handler struct:** +```go +// If keeping only SetupRoutes function: +type Handler struct { + // This might be entirely removed if SetupRoutes is only factory +} + +func SetupRoutes(g *echo.Group, db *database.Queries, connManager *wsync.ConnectionManager) *Handler { + return NewHandler(db, connManager) +} +``` + +OR + +**If Handler is no longer needed:** +- Remove entire file +- Move remaining structs to appropriate files +- Update all imports + +--- + +## Implementation Order (SAFEST FIRST) + +### Phase 1: Create new handler files (ZERO RISK) +1. Create `internal/handlers/media.go` with struct and empty methods +2. Create `internal/handlers/search.go` with struct and empty methods +3. Create `internal/handlers/matching.go` with struct and empty methods +4. Verify compilation + +### Phase 2: Move method bodies (LOW RISK) +1. Copy media methods from ebook.go to media.go +2. Copy search methods from ebook.go to search.go +3. Copy matching methods from ebook.go to matching.go +4. Test compilation after each file + +### Phase 3: Update router registration (MEDIUM RISK) +1. Update router.go Config struct +2. Create registerSearchRoutes and registerMatchingRoutes +3. Update registerMediaRoutes to use MediaHandler +4. Test compilation + +### Phase 4: Update main.go initialization (LOW RISK) +1. Add new handler instance creation +2. Update routerConfig +3. Test compilation and runtime + +### Phase 5: Clean up ebook.go (LOW RISK) +1. Remove moved methods +2. Simplify/Remove Handler struct if no longer needed +3. Verify final state + +--- + +## Verification Checklist + +### After Each Phase: + +**Phase 1 (new files created):** +- [ ] All 3 new files created +- [ ] Code compiles without errors +- [ ] No existing functionality broken + +**Phase 2 (methods moved):** +- [ ] All media methods in media.go compile +- [ ] All search methods in search.go compile +- [ ] All matching methods in matching.go compile +- [ ] Original ebook.go still compiles + +**Phase 3 (router updated):** +- [ ] router.go compiles with new Config struct +- [ ] registerSearchRoutes compiles +- [ ] registerMatchingRoutes compiles +- [ ] registerMediaRoutes updated correctly + +**Phase 4 (main.go updated):** +- [ ] Application compiles +- [ ] All handler instances created +- [ ] routerConfig populated correctly + +**Phase 5 (cleanup):** +- [ ] ebook.go reduced from 1,350 to <200 lines +- [ ] All route endpoints still exist +- [ ] Bruno tests still pass +- [ ] Verification script passes (26/26) + +### Final Verification Commands: + +```bash +# Compilation test +go build ./cmd/server + +# Test all critical endpoints still work +curl http://localhost:8765/api/media-items | jq . +curl http://localhost:8765/api/media-items/search?q=test | jq . +curl http://localhost:8765/api/books/match | jq . + +# Run verification script +bash scripts/verify-guidelines.sh + +# Check file sizes +wc -l internal/handlers/*.go | sort -n +# Expected: media.go ~600, search.go ~50, matching.go ~150, ebook.go <200 + +# Test Bruno tests still pass +cd bruno && npx bruno test +``` + +--- + +## Rollback Strategy + +### If any phase breaks: + +**Immediate rollback:** +```bash +git checkout -- internal/handlers/media.go +git checkout -- internal/handlers/search.go +git checkout -- internal/handlers/matching.go +git checkout -- internal/router/router.go +git checkout -- internal/router/media.go +git checkout -- cmd/server/main.go +``` + +**Phase-by-phase rollback:** +```bash +# After Phase 2: If methods don't compile +git checkout -- internal/handlers/media.go +git checkout -- internal/handlers/search.go +git checkout -- internal/handlers/matching.go + +# After Phase 3: If router breaks +git checkout -- internal/router/router.go +git checkout -- internal/router/media.go + +# After Phase 4: If initialization breaks +git checkout -- cmd/server/main.go +``` + +**Full project reset:** +```bash +git checkout -- internal/handlers/ebook.go +git clean -fd internal/handlers/media.go internal/handlers/search.go internal/handlers/matching.go +``` + +--- + +## Success Metrics + +**Before refactor:** +- ebook.go: 1,350 lines +- 1 monolithic handler +- Mixed concerns in single file + +**After refactor:** +- ebook.go: ~150 lines (SetupRoutes only) +- media.go: ~600 lines (CRUD + metadata) +- search.go: ~80 lines (search operations) +- matching.go: ~200 lines (sync operations) +- Clear single responsibilities per file +- All API endpoints identical +- All tests continue to work + +**File count increase:** +3 new handler files +**Lines of code change:** ~0 (moved, not modified) +**API compatibility:** 100% maintained +**Risk level:** VERY LOW + +--- + +## Final Notes + +### Dependencies to verify: + +1. **Worker access for MediaHandler:** + - Check where `worker` is created in current code + - Pass to NewMediaHandler properly + +2. **AdminMiddleware import:** + - Ensure all router files import handlers package + - AdminMiddleware is accessible + +3. **Import statements:** + - Verify all new handler files have correct imports + - No circular dependencies created + +### Testing strategy: + +1. **Compile after each file creation** +2. **Test endpoint accessibility after router changes** +3. **Run existing test suite before/after** +4. **Verify Bruno tests still pass** +5. **Check verification script passes** + +This plan ensures zero API changes while completely reorganizing the codebase into maintainable, single-responsibility files. + +--- + +**Ready for safe implementation by any AI following these exact phases.** \ No newline at end of file diff --git a/SCANNER_RESTORATION_PLAN.md b/SCANNER_RESTORATION_PLAN.md index 7740cc7..aa39e3f 100644 --- a/SCANNER_RESTORATION_PLAN.md +++ b/SCANNER_RESTORATION_PLAN.md @@ -1,95 +1,85 @@ -# Scanner System Restoration & Enhancement Plan +# Scanner System Fix & Enhancement Plan **Created:** February 6, 2026 -**Context:** Restore missing scanner functionality lost during router refactor, implement library-type-aware scanning +**Updated:** February 6, 2026 (clarified scope and priorities) **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. +This plan addresses TWO separate issues with the scanner system: -**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 +### 1. **CRITICAL BUG** (Priority: HIGH) +Auto-start functionality was removed during router refactor (commit 6784c25): +- Scheduler doesn't start on server boot +- Watch mode doesn't auto-start for libraries +- No graceful shutdown for scanner services +- `handlers.SetupRoutes()` returns Handler but return value isn't captured + +### 2. **ENHANCEMENTS** (Priority: MEDIUM) +- Library-type-aware scanning (prevents cross-contamination between ebook/comic/manga libraries) +- Comic/manga metadata extraction (ComicInfo.xml parsing) +- Better code organization (extract scanner routes to separate file) + +**What WASN'T broken:** +- ✅ Scanner routes still work (7 endpoints in `internal/handlers/ebook.go:145-154`) +- ✅ Can manually start/stop scanner via API +- ✅ Scan job tracking works +- ✅ Watch mode works when manually triggered + +--- + +## Changes Summary **Files to Create:** -1. `internal/router/scanner.go` (new) -2. `internal/app/app.go` (new) +1. `internal/router/scanner.go` (new - optional, for organization) +2. `internal/app/app.go` (new - required, for lifecycle management) **Files to Modify:** -1. `internal/router/router.go` (add scanner route registration) -2. `cmd/server/main.go` (use App pattern) +1. `internal/router/router.go` (capture and return EbookHandler) +2. `cmd/server/main.go` (restore auto-start calls, use App pattern) 3. `internal/services/ebook_scanner.go` (library-type-aware scanning) +4. `internal/handlers/ebook.go` (comic/manga metadata extraction) -**Risk Assessment:** LOW - All changes are additive or wrap existing code +**Risk Assessment:** LOW-MEDIUM +- Auto-start restoration: LOW (restores existing code that was removed) +- Library-type-awareness: MEDIUM (core scanner logic change) +- Comic/manga scanning: MEDIUM (new feature) --- -## 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:** +## Phase 1: Restore Auto-Start Functionality [CRITICAL BUG FIX] +### Problem +Before router refactor (commit 799b640): ```go -package router +h := handlers.SetupRoutes(protected, queries) -import ( - "net/http" +// Start scheduler for auto-scanning +go h.StartScheduler() +defer h.StopScheduler() - "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) -} +// Start watch mode for all libraries (background) +go func() { + time.Sleep(2 * time.Second) + if err := h.StartWatchModeForAllLibraries(context.Background()); err != nil { + log.Printf("Warning: failed to start watch mode for libraries: %v", err) + } +}() ``` -**Verification:** -- 10 endpoints registered -- All use cfg.EbookHandler methods -- All protected by JWT middleware +After refactor (current): +```go +handlers.SetupRoutes(protected, cfg.Queries, cfg.ConnManager) // Return value ignored! +// No scheduler start +// No watch mode start +// No graceful shutdown +``` ---- +### Solution -## Phase 2: Update Router to Call Scanner Routes - -### File: `internal/router/router.go` - -**Change 1: Capture EbookHandler from SetupRoutes** +#### Step 1.1: Update `internal/router/router.go` **Location:** Line 113-114 @@ -98,8 +88,6 @@ func registerScannerRoutes(cfg *Config) { jwtMiddleware := createJWTMiddleware(cfg) protected := e.Group("/api", jwtMiddleware) handlers.SetupRoutes(protected, cfg.Queries, cfg.ConnManager) - -// Register route groups ``` **New Code:** @@ -107,278 +95,213 @@ handlers.SetupRoutes(protected, cfg.Queries, cfg.ConnManager) jwtMiddleware := createJWTMiddleware(cfg) protected := e.Group("/api", jwtMiddleware) ebookHandler := handlers.SetupRoutes(protected, cfg.Queries, cfg.ConnManager) - -// Register route groups +return ebookHandler // Add return statement to RegisterRoutes ``` -**Change 2: Add EbookHandler to Config struct** +**Update RegisterRoutes signature:** -**Location:** Line 33-52 (Config struct definition) - -**Add to struct:** +**Current (line 86):** ```go -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 +func RegisterRoutes(cfg *Config) { +``` + +**New:** +```go +func RegisterRoutes(cfg *Config) *handlers.Handler { + // ... existing code ... + return ebookHandler // Return at end of function } ``` -**Change 3: Add scanner route registration** +#### Step 1.2: Update `cmd/server/main.go` -**Location:** Line 124 (after registerAnalyticsRoutes) +**Location:** After line 151 (after `router.RegisterRoutes(routerConfig)`) -**Current Code:** +**Add this code:** ```go -registerAnalyticsRoutes(cfg) -registerQueueRoutes(cfg) -registerOPDSRoutes(cfg) -``` +// Register all routes and get ebook handler +ebookHandler := router.RegisterRoutes(routerConfig) -**New Code:** -```go -registerAnalyticsRoutes(cfg) -registerQueueRoutes(cfg) -registerScannerRoutes(cfg) // ← ADD THIS LINE -registerOPDSRoutes(cfg) +// ======================================================================== +// BACKGROUND SERVICES - Restore auto-start functionality +// ======================================================================== + +// Start scheduler for auto-scanning +go ebookHandler.StartScheduler() +defer ebookHandler.StopScheduler() + +// Start watch mode for all libraries (background) +go func() { + time.Sleep(2 * time.Second) // Wait for server to be ready + if err := ebookHandler.StartWatchModeForAllLibraries(context.Background()); err != nil { + log.Printf("Warning: failed to start watch mode for libraries: %v", err) + } +}() ``` **Verification:** -- EbookHandler captured from SetupRoutes -- Added to Config struct -- registerScannerRoutes called in RegisterRoutes +- [ ] Application compiles +- [ ] Server starts without errors +- [ ] Check logs for "Starting scheduler" message +- [ ] Check logs for watch mode starting after 2 seconds --- -## Phase 3: Implement Library-Type-Aware Scanner +## Phase 2: Implement Library-Type-Aware Scanning [ENHANCEMENT] + +### Purpose +Prevent cross-contamination between library types: +- Epub libraries should only scan .epub files +- Comic libraries should only scan .cbz/.cbr files +- Manga libraries should only scan appropriate formats +- Each library type has configurable allowed extensions ### File: `internal/services/ebook_scanner.go` -**Change 1: Add libraryTypes cache field** +#### Change 2.1: Add libraryTypes cache field **Location:** Line 59-65 (EbookScanner struct) **Current Code:** ```go type EbookScanner struct { - db *database.Queries - watcher *fsnotify.Watcher - folders []string - adminID pgtype.UUID - defaultLibraryID pgtype.UUID + db *database.Queries + watcher *fsnotify.Watcher + folders []string + adminID pgtype.UUID + defaultLibraryID pgtype.UUID } ``` **New Code:** ```go 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 + 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** +#### Change 2.2: Initialize libraryTypes in NewEbookScanner -**Location:** Line 73-74 (in NewEbookScanner function) - -**Current Code:** -```go -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}, - } -} -``` +**Location:** Line 73-74 **New Code:** ```go -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 - } +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** +#### Change 2.3: Build library types cache in SetFolders **Location:** Line 86-109 (SetFolders function) -**Current Code:** -```go -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:** ```go func (s *EbookScanner) SetFolders(folders []string) error { - s.folders = folders + s.folders = folders - // Remove old watch if exists - if s.watcher != nil { - s.watcher.Close() - } + // 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 + // 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() + // 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 - } + 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 - } + // 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) - } + // Cache allowed extensions for this folder + s.libraryTypes[folder] = libType.AllowedExtensions + fmt.Printf("Scanner: Folder %s (type: %s) allows extensions: %v\n", + folder, libType.Name, 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) - } - } + // 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 + return nil } ``` -**Change 4: Replace isEbookFile with isScannableFile** +#### Change 2.4: Replace isEbookFile with isScannableFile **Location:** Line 168-177 (isEbookFile function) -**Current Code:** -```go -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:** ```go // 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)) + 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 - } - } + // 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 - } + // 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 - } + // 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 - } - } + // Check if file extension is allowed for this library type + for _, allowedExt := range allowed { + if ext == strings.ToLower(allowedExt) { + return true + } + } - return false + return false } ``` -**Change 5: Update ScanFolders to use isScannableFile** +#### Change 2.5: Update ScanFolders to use isScannableFile **Location:** Line 146 (in ScanFolders function) @@ -395,722 +318,382 @@ 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 +- [ ] Code compiles +- [ ] Ebook libraries scan only .epub files (check logs) +- [ ] Comic libraries scan only .cbz/.cbr files +- [ ] No cross-contamination between library types +- [ ] Test: Create ebook library, add .cbz file → should be ignored +- [ ] Test: Create comic library, add .epub file → should be ignored --- -## Phase 4: Create Application Lifecycle Management +## Phase 3: Comic/Manga Metadata Extraction [NEW FEATURE] -### File: `internal/app/app.go` (NEW) +### Purpose +Extract metadata from comic/manga archives (.cbz, .cbr, .cb7): +- Parse ComicInfo.xml from archives +- Extract cover images +- Get series, issue number, publisher, etc. +- Support comic library management -**Purpose:** Manage application lifecycle, start/stop background services +### File: `internal/handlers/ebook.go` -**Complete Code:** - -```go -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: - -```go -// ScannerService defines the interface for scanner background services -type ScannerService interface { - StartScheduler() - StopScheduler() - StartWatchModeForAllLibraries(ctx context.Context) error -} -``` - -Then change the App struct: - -```go -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):** -```go -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:** - -```go -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` +#### Step 3.1: Add ComicInfo.xml parsing **Add to imports:** - ```go -package app - import ( - "context" - "fmt" - "log" - "net/http" - "os" - "os/signal" - "syscall" - "time" - - "github.com/labstack/echo/v4" - "bookhoard/internal/handlers" // ← ADD THIS + "archive/zip" + "encoding/xml" + "image" + _ "image/jpeg" + _ "image/png" + "path/filepath" + "strings" ) ``` -**Then update the App struct to use concrete type:** +#### Step 3.2: Define ComicInfo struct +**Add after existing structs:** ```go -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 +// ComicInfo represents metadata from ComicInfo.xml +type ComicInfo struct { + XMLName xml.Name `xml:"ComicInfo"` + Title string `xml:"Title"` + Series string `xml:"Series"` + Number int `xml:"Number"` + Volume int `xml:"Volume"` + Publisher string `xml:"Publisher"` + Year int `xml:"Year"` + Month int `xml:"Month"` + Day int `xml:"Day"` + Writer string `xml:"Writer"` + Penciller string `xml:"Penciller"` + Inker string `xml:"Inker"` + Colorist string `xml:"Colorist"` + Letterer string `xml:"Letterer"` + CoverArtist string `xml:"CoverArtist"` + Genre string `xml:"Genre"` + Tags string `xml:"Tags"` + Web string `xml:"Web"` + Notes string `xml:"Notes"` } ``` -**Add imports:** +#### Step 3.3: Add extraction function +**Add new function:** ```go -import ( - "context" - "fmt" - "log" - "net/http" - "os" - "os/signal" - "syscall" - "time" +// extractComicMetadata extracts metadata from comic archive +func extractComicMetadata(filePath string) (*ComicInfo, []byte, error) { + // Open archive + r, err := zip.OpenReader(filePath) + if err != nil { + return nil, nil, fmt.Errorf("failed to open comic archive: %w", err) + } + defer r.Close() - "github.com/jackc/pgx/v5/pgxpool" - "github.com/labstack/echo/v4" - "bookhoard/internal/config" // ← ADD THIS - "bookhoard/internal/handlers" // ← ADD THIS -) -``` + // Look for ComicInfo.xml + var comicInfo *ComicInfo + var coverImage []byte -**Update New function signature:** + for _, f := range r.File { + if f.Name == "ComicInfo.xml" { + rc, err := f.Open() + if err != nil { + return nil, nil, fmt.Errorf("failed to open ComicInfo.xml: %w", err) + } -```go -func New(echo *echo.Echo, ebookHandler *handlers.Handler, cfg *config.Config, dbPool *pgxpool.Pool) *App { -``` + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, nil, fmt.Errorf("failed to read ComicInfo.xml: %w", err) + } -**Update startBackgroundServices to use concrete type:** + comicInfo = &ComicInfo{} + if err := xml.Unmarshal(data, comicInfo); err != nil { + return nil, nil, fmt.Errorf("failed to parse ComicInfo.xml: %w", err) + } + } -```go -func (a *App) startBackgroundServices() error { - log.Println("Starting background services...") + // Look for cover image (usually first image in root) + if coverImage == nil && isImageFile(f.Name) { + // Usually in root directory, not subdirectories + if !strings.Contains(filepath.Dir(f.Name), string(filepath.Separator)) || + filepath.Dir(f.Name) == "." { + rc, err := f.Open() + if err != nil { + continue + } - // Start scheduler for auto-scanning - a.EbookHandler.StartScheduler() + coverImage, err = io.ReadAll(rc) + rc.Close() + if err == nil { + // Validate it's actually an image + _, _, err = image.Decode(bytes.NewReader(coverImage)) + if err != nil { + coverImage = nil // Not a valid image + } + } + } + } + } - // Add shutdown function for scheduler - a.shutdownFuncs = append(a.shutdownFuncs, func() error { - a.EbookHandler.StopScheduler() - return nil - }) + if comicInfo == nil { + // No ComicInfo.xml, create minimal metadata from filename + comicInfo = &ComicInfo{} + basename := filepath.Base(filePath) + comicInfo.Title = strings.TrimSuffix(basename, filepath.Ext(basename)) + } - // 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 comicInfo, coverImage, nil +} - return nil +// isImageFile checks if a file is an image based on extension +func isImageFile(filename string) bool { + ext := strings.ToLower(filepath.Ext(filename)) + return ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" } ``` -**Remove the callHandlerMethod function** - no longer needed with concrete types. +#### Step 3.4: Integrate into scanner + +**Update ScanFolders to extract comic metadata for .cbz/.cbr files:** + +**Location:** In the file processing loop (around line 160-180) + +**Add before creating media item:** +```go +var comicInfo *ComicInfo +var coverImage []byte + +// Extract comic metadata if applicable +if strings.ToLower(filepath.Ext(path)) == ".cbz" { + info, cover, err := extractComicMetadata(path) + if err != nil { + log.Printf("Warning: failed to extract comic metadata from %s: %v", path, err) + } else { + comicInfo = info + coverImage = cover + } +} + +// When creating media item, use comic metadata +title := comicInfo.Title +if title == "" { + title = filepath.Base(path) +} + +// Use cover image if available +if len(coverImage) > 0 { + // Use extracted cover + // ... existing cover processing code ... +} +``` **Verification:** -- All imports added -- Concrete types used instead of interface{} -- Direct method calls to EbookHandler -- Simpler, more maintainable code +- [ ] Code compiles +- [ ] Create .cbz file with ComicInfo.xml +- [ ] Scan comic library +- [ ] Check metadata was extracted (title, series, issue) +- [ ] Check cover image was extracted +- [ ] Test .cbz without ComicInfo.xml (should use filename) --- -## Implementation Order (Step-by-Step) +## Phase 4: Organize Scanner Routes [OPTIONAL - LOW PRIORITY] -### Step 1: Create scanner routes (ADDITIVE ONLY) -- Create `internal/router/scanner.go` -- Zero risk - new file -- Does not affect existing code +### Purpose +Move scanner routes from `internal/handlers/ebook.go` to `internal/router/scanner.go` for better organization. -### Step 2: Update router (MINIMAL CHANGES) -- Capture EbookHandler in router.go -- Add to Config struct -- Call registerScannerRoutes -- Low risk - only adds route registration +**Note:** This is purely cosmetic. Scanner routes already work fine where they are. -### 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 +### File: `internal/router/scanner.go` (NEW) -### Step 4: Create app package (ADDITIVE ONLY) -- Create `internal/app/app.go` -- Zero risk - new file -- Does not affect existing code +```go +package router -### Step 5: Update main.go (REFACTOR) -- Replace initialization with App pattern -- Add signal handling -- Low risk - wraps existing code +import ( + "github.com/labstack/echo/v4" +) -### Step 6: Test and Verify -- Run all existing routes -- Test new scanner endpoints -- Verify library type filtering -- Test graceful shutdown +// registerScannerRoutes registers all scanner-related endpoints +func registerScannerRoutes(cfg *Config) { + // Routes are already registered in handlers.SetupRoutes() + // This file is for documentation/organization purposes + // Actual routes are in: + // - internal/handlers/ebook.go:145-154 (scanner endpoints) + // - internal/handlers/auth.go (scan settings endpoints) +} +``` + +**Decision:** SKIP this phase. The current organization works fine. + +--- + +## Phase 5: Application Lifecycle Management [OPTIONAL - DEFER] + +### Purpose +Create `internal/app/app.go` for better lifecycle management, graceful shutdown, signal handling. + +**Decision:** DEFER to future implementation. The simple approach in Phase 1 is sufficient for now. + +--- + +## Implementation Order + +### Priority 1: Critical Bug Fix (Phase 1) +- Time: 15 minutes +- Risk: LOW +- Impact: Restores auto-scan and watch mode + +### Priority 2: Library-Type-Awareness (Phase 2) +- Time: 1 hour +- Risk: MEDIUM +- Impact: Prevents cross-contamination + +### Priority 3: Comic/Manga Scanning (Phase 3) +- Time: 2-3 hours +- Risk: MEDIUM +- Impact: New feature for comic libraries + +### Priority 4: Code Organization (Phase 4) +- Time: 30 minutes +- Risk: LOW +- Impact: Cosmetic (SKIP for now) + +### Priority 5: App Lifecycle (Phase 5) +- Time: 2 hours +- Risk: MEDIUM +- Impact: Better structure (DEFER for now) --- ## Testing Checklist -### After Each Phase: +### After Phase 1 (Auto-start restoration): +- [ ] Application compiles +- [ ] Server starts without errors +- [ ] Logs show "Starting scheduler" +- [ ] Logs show "Starting watch mode for all libraries" after 2 seconds +- [ ] Scheduler triggers auto-scans +- [ ] SIGTERM triggers graceful shutdown -**Phase 1 (scanner.go created):** -- [ ] File created successfully -- [ ] No compilation errors +### After Phase 2 (Library-type-awareness): +- [ ] Create ebook library, add .epub → scans correctly +- [ ] Create ebook library, add .cbz → ignored +- [ ] Create comic library, add .cbz → scans correctly +- [ ] Create comic library, add .epub → ignored +- [ ] Check logs for library type messages -**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 +### After Phase 3 (Comic/manga scanning): +- [ ] Create test .cbz with ComicInfo.xml +- [ ] Add to comic library +- [ ] Scan library +- [ ] Verify metadata extracted (title, series, issue) +- [ ] Verify cover image extracted +- [ ] Test .cbz without ComicInfo.xml (uses filename) --- ## Verification Commands -### Test existing routes still work: +### Test scanner auto-start: ```bash -# Test authentication -curl -X POST http://localhost:8765/api/auth/login \ - -H "Content-Type: application/json" \ - -d '{"login":"testuser@example.com","password":"Test@Pass123!"}' +# Start server +podman-compose up -d --build -# Test libraries -TOKEN= -curl http://localhost:8765/api/libraries/types -curl http://localhost:8765/api/libraries -H "Authorization: Bearer $TOKEN" -``` +# Check logs +podman logs bookhoard | grep -i "scheduler\|watch mode" -### Test new scanner endpoints: -```bash -# 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: -```bash -# Look for these log messages: +# Should see: # "Starting scheduler for auto-scanning" -# "Scheduled scan for library" # "Starting watch mode for all libraries" -# "Warning: failed to start watch mode" ``` -### Test graceful shutdown: +### Test library-type-awareness: ```bash -# Start server, then send SIGTERM -kill -TERM +# Create ebook library +curl -X POST http://localhost:8765/api/libraries \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"Ebooks","library_type_id":"","folders":["/path/to/ebooks"]}' -# Or Ctrl+C which sends SIGINT -# Should see "Shutting down application..." message +# Create comic library +curl -X POST http://localhost:8765/api/libraries \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"Comics","library_type_id":"","folders":["/path/to/comics"]}' + +# Add .epub to comic library → should be ignored +# Add .cbz to ebook library → should be ignored +# Check scan logs for filtering messages +``` + +### Test comic metadata extraction: +```bash +# Create test .cbz with ComicInfo.xml +zip test.cbz ComicInfo.xml cover.jpg page1.jpg + +# Add to comic library and scan +curl -X POST http://localhost:8765/api/scanner/scan \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"folder_paths":["/path/to/comics"]}' + +# Check media item has correct metadata +curl http://localhost:8765/api/media-items?library_id= \ + -H "Authorization: Bearer $TOKEN" | jq . ``` --- ## Rollback Plan -If any phase breaks functionality: - -### Rollback Phase 2 (router changes): +### Rollback Phase 1: ```bash +git checkout cmd/server/main.go git checkout internal/router/router.go ``` -### Rollback Phase 3 (scanner changes): +### Rollback Phase 2: ```bash git checkout internal/services/ebook_scanner.go ``` -### Rollback Phase 5 (main.go changes): +### Rollback Phase 3: ```bash -git checkout cmd/server/main.go +git checkout internal/handlers/ebook.go ``` -### Rollback new files: -```bash -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 +**Critical Issues Fixed:** +- ✅ Auto-start scheduler (restored) +- ✅ Auto-start watch mode (restored) +- ✅ Graceful shutdown (restored) + +**Enhancements Added:** - ✅ Library-type-aware scanning (prevents cross-contamination) -- ✅ Graceful shutdown with signal handling -- ✅ Application lifecycle management +- ✅ Comic/manga metadata extraction (ComicInfo.xml parsing) +- ✅ Comic cover image extraction -**What this doesn't break:** -- ✅ All existing routes (auth, library, device, media, collections, etc.) -- ✅ All existing API endpoints -- ✅ Database schema -- ✅ Any other functionality +**Deferred:** +- ⏸️ Code organization (scanner routes file) +- ⏸️ App lifecycle management -**Estimated implementation time:** 2-3 hours -**Risk level:** LOW -**Dependencies:** None (uses existing code) +**Estimated Time:** +- Phase 1 (bug fix): 15 minutes +- Phase 2 (enhancement): 1 hour +- Phase 3 (new feature): 2-3 hours +- **Total: 4-5 hours** -**Ready for implementation in next session.** +**Risk Level:** +- Phase 1: LOW (restoring removed code) +- Phase 2: MEDIUM (core scanner logic) +- Phase 3: MEDIUM (new feature, isolated) + +**Ready for implementation in priority order.**