feat(scanner): add debounced file watching with polling fallback

- Implement event queue with 3-second debouncing for file system events
- Add configurable polling fallback (default 3 min) via SCAN_POLL_INTERVAL_MINUTES
- Add SyncFilesystemWithDatabase to detect orphaned DB entries and new files
- Integrate utils.ResolveMediaURL for consistent media file path resolution
- Add COOKIE_SECURE env var with SameSite=LaxMode for session cookies
- Update media handler to properly decode URL paths for file serving
- Refactor scanner initialization to accept poll interval configuration
This commit is contained in:
2026-02-28 01:16:27 -05:00
parent 594c630b99
commit 037e7c1189
14 changed files with 485 additions and 113 deletions
+7 -2
View File
@@ -6,6 +6,7 @@ import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@@ -29,6 +30,8 @@ const (
// Note: This is computed from SessionDuration to avoid magic numbers
var SessionDurationSec = int(SessionDuration.Seconds())
var secure = os.Getenv("COOKIE_SECURE")
type AuthHandler struct {
db *database.Queries
jwtKey []byte
@@ -249,7 +252,8 @@ func (h *AuthHandler) Register(c echo.Context) error {
Value: accessToken,
Path: "/",
HttpOnly: true,
Secure: false, // TODO: Set to true in production with HTTPS
Secure: secure == "true", // TODO: Set to true in production with HTTPS
SameSite: http.SameSiteLaxMode,
MaxAge: SessionDurationSec,
}
c.SetCookie(cookie)
@@ -394,7 +398,8 @@ func (h *AuthHandler) Login(c echo.Context) error {
Value: accessToken,
Path: "/",
HttpOnly: true,
Secure: false, // TODO: Set to true in production with HTTPS
Secure: secure == "true", // TODO: Set to true in production with HTTPS
SameSite: http.SameSiteLaxMode,
MaxAge: SessionDurationSec,
}
c.SetCookie(cookie)
+2 -2
View File
@@ -928,10 +928,10 @@ func mediaItemsToListMediaItemsRow(item database.MediaItems) database.ListMediaI
Author: item.Author,
Isbn: item.Isbn,
Description: item.Description,
FilePath: item.FilePath,
FilePath: utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: true}),
FileSize: item.FileSize,
MimeType: item.MimeType,
CoverImagePath: item.CoverImagePath,
CoverImagePath: pgtype.Text{String: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), Valid: true},
Series: item.Series,
SeriesNumber: item.SeriesNumber,
Tags: item.Tags,
+6 -4
View File
@@ -1,6 +1,7 @@
package handlers
import (
"bookhoard/internal/config"
"bookhoard/internal/database"
"bookhoard/internal/services"
wsync "bookhoard/internal/sync"
@@ -27,9 +28,10 @@ type Handler struct {
watchingLibraries map[string]bool
connManager *wsync.ConnectionManager
cleanupTaskCancel context.CancelFunc
config *config.Config
}
func NewHandler(db *database.Queries, connManager *wsync.ConnectionManager, queueProcessor *wsync.SyncQueueProcessor) *Handler {
func NewHandler(db *database.Queries, connManager *wsync.ConnectionManager, queueProcessor *wsync.SyncQueueProcessor, cfg *config.Config) *Handler {
ctx, cancel := context.WithCancel(context.Background())
worker := services.NewWorker(3)
scheduler := services.NewScheduler(worker, db)
@@ -40,7 +42,7 @@ func NewHandler(db *database.Queries, connManager *wsync.ConnectionManager, queu
return &Handler{
db: db,
scanner: services.NewMediaScanner(db),
scanner: services.NewMediaScanner(db, cfg.ScanPollIntervalMinutes),
worker: worker,
scheduler: scheduler,
queueProcessor: queueProcessor,
@@ -66,6 +68,6 @@ func parseDate(dateStr string) time.Time {
return time.Time{}
}
func SetupRoutes(g *echo.Group, db *database.Queries, connManager *wsync.ConnectionManager, queueProcessor *wsync.SyncQueueProcessor) *Handler {
return NewHandler(db, connManager, queueProcessor)
func SetupRoutes(g *echo.Group, db *database.Queries, connManager *wsync.ConnectionManager, queueProcessor *wsync.SyncQueueProcessor, cfg *config.Config) *Handler {
return NewHandler(db, connManager, queueProcessor, cfg)
}
+2 -1
View File
@@ -3,6 +3,7 @@ package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
"bookhoard/internal/utils"
"net/http"
"strconv"
@@ -151,7 +152,7 @@ func BuildSections(sections []services.DashboardSection) []SectionData {
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: textToString(item.CoverImagePath),
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
}
}
+13 -10
View File
@@ -9,6 +9,7 @@ import (
"io"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
@@ -1490,21 +1491,23 @@ func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UU
// Requires JWT authentication
func (mh *MediaHandler) ServeFile(c echo.Context) error {
// URL format: /uploads/library-{libraryID}/{relativePath}
path := c.Param("*") // Gets everything after /uploads/library-{id}/
// Extract library ID from path
parts := strings.SplitN(path, "/", 2)
if len(parts) < 2 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid path"})
}
libraryIDStr := strings.TrimPrefix(parts[0], "library-")
// Get library ID directly from route parameter
libraryIDStr := c.Param("id")
libraryUUID, err := uuid.Parse(libraryIDStr)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library ID"})
}
relativePath := parts[1]
// Get remaining path from URL
rawPath := c.Param("*")
relativePath, err := url.QueryUnescape(rawPath)
if err != nil {
relativePath = rawPath
}
if relativePath == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid path"})
}
// Resolve using service
fullPath, err := mh.getFullFilePath(c.Request().Context(), pgtype.UUID{Bytes: libraryUUID, Valid: true}, relativePath)
+1 -1
View File
@@ -197,7 +197,7 @@ func (h *Handler) StartWatchModeForLibrary(ctx context.Context, libraryID pgtype
folderPaths[i] = folder.FolderPath
}
scanner := services.NewMediaScanner(h.db)
scanner := services.NewMediaScanner(h.db, 3)
if err := scanner.SetFolders(folderPaths); err != nil {
return fmt.Errorf("failed to set scanner folders: %v", err)
}