feat: implement multiple folder ebook scanner and API handlers

- Create EbookScanner service with multiple folder support
- Update scanner to handle array of folder paths instead of single path
- Add EPUB metadata extraction for rich ebook information
- Implement folder monitoring with fsnotify for real-time updates
- Add API endpoints for folder management:
  - POST /api/auth/ebook-folders (add folder)
  - GET /api/auth/ebook-folders (list folders)
  - DELETE /api/auth/ebook-folders/:folderPath (remove folder)
- Update scanner endpoints to accept folder_paths array
- Add go-epub and fsnotify dependencies for metadata extraction and file watching
This commit is contained in:
2026-01-23 09:02:53 -05:00
parent 9019e524e0
commit 9fc5d3baea
5 changed files with 493 additions and 7 deletions
+93
View File
@@ -315,6 +315,99 @@ func (h *AuthHandler) ListUsers(c echo.Context) error {
return c.JSON(http.StatusOK, userList)
}
type AddEbookFolderRequest struct {
FolderPath string `json:"folder_path" validate:"required"`
}
type EbookFolderResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
FolderPath string `json:"folder_path"`
CreatedAt string `json:"created_at"`
}
// AddEbookFolder handles POST /api/auth/ebook-folders
func (h *AuthHandler) AddEbookFolder(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
var req AddEbookFolderRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
folder, err := h.db.AddUserEbookFolder(c.Request().Context(), database.AddUserEbookFolderParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
FolderPath: req.FolderPath,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, EbookFolderResponse{
ID: uuid.UUID(folder.ID.Bytes).String(),
UserID: uuid.UUID(folder.UserID.Bytes).String(),
FolderPath: folder.FolderPath,
CreatedAt: folder.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
})
}
// GetEbookFolders handles GET /api/auth/ebook-folders
func (h *AuthHandler) GetEbookFolders(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
folders, err := h.db.GetUserEbookFolders(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
var response []EbookFolderResponse
for _, folder := range folders {
response = append(response, EbookFolderResponse{
ID: uuid.UUID(folder.ID.Bytes).String(),
UserID: uuid.UUID(folder.UserID.Bytes).String(),
FolderPath: folder.FolderPath,
CreatedAt: folder.CreatedAt.Time.Format("2006-01-02T15:04:05Z07:00"),
})
}
return c.JSON(http.StatusOK, response)
}
// DeleteEbookFolder handles DELETE /api/auth/ebook-folders/:folderPath
func (h *AuthHandler) DeleteEbookFolder(c echo.Context) error {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
}
folderPath := c.Param("folderPath")
if folderPath == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "folder path is required"})
}
err = h.db.DeleteUserEbookFolder(c.Request().Context(), database.DeleteUserEbookFolderParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
FolderPath: folderPath,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "ebook folder removed"})
}
func (h *AuthHandler) generateJWT(userID string) (string, error) {
claims := jwtgo.MapClaims{
"user_id": userID,
+81 -2
View File
@@ -2,8 +2,11 @@ package handlers
import (
"bookmann/internal/database"
"bookmann/internal/services"
"context"
"net/http"
"strconv"
"sync"
"time"
"github.com/google/uuid"
@@ -12,12 +15,20 @@ import (
)
type Handler struct {
db *database.Queries
db *database.Queries
scanner *services.EbookScanner
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
}
func NewHandler(db *database.Queries) *Handler {
ctx, cancel := context.WithCancel(context.Background())
return &Handler{
db: db,
db: db,
scanner: services.NewEbookScanner(db),
ctx: ctx,
cancel: cancel,
}
}
@@ -49,6 +60,11 @@ func SetupRoutes(g *echo.Group, db *database.Queries) {
g.PUT("/ebooks/:id/rating", h.CreateOrUpdateEbookRating)
g.DELETE("/ebooks/:id/rating", h.DeleteEbookRating)
g.GET("/ebooks/:id/ratings", h.GetEbookRatings)
// Scanner routes
g.POST("/scanner/scan", h.ScanEbooks)
g.POST("/scanner/start", h.StartScanner)
g.POST("/scanner/stop", h.StopScanner)
}
// ListEbooks handles GET /api/ebooks
@@ -407,3 +423,66 @@ func (h *Handler) GetEbookRatings(c echo.Context) error {
return c.JSON(http.StatusOK, ratings)
}
// ScanEbooksRequest represents the request for scanning ebooks
type ScanEbooksRequest struct {
FolderPaths []string `json:"folder_paths" validate:"required,min=1"`
}
// ScanEbooks handles POST /api/scanner/scan
func (h *Handler) ScanEbooks(c echo.Context) error {
var req ScanEbooksRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Set the folder paths for scanning
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
}
// Perform the scan
if err := h.scanner.ScanFolders(h.ctx); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "scan failed: " + err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"message": "scan completed"})
}
// StartScanner handles POST /api/scanner/start
func (h *Handler) StartScanner(c echo.Context) error {
h.mu.Lock()
defer h.mu.Unlock()
var req ScanEbooksRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// Set the folder paths
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
}
// Start watching for changes
h.scanner.WatchChanges(h.ctx)
return c.JSON(http.StatusOK, map[string]string{"message": "scanner started"})
}
// StopScanner handles POST /api/scanner/stop
func (h *Handler) StopScanner(c echo.Context) error {
h.mu.Lock()
defer h.mu.Unlock()
h.cancel()
h.ctx, h.cancel = context.WithCancel(context.Background())
return c.JSON(http.StatusOK, map[string]string{"message": "scanner stopped"})
}