Files
bookhoard/internal/handlers/system_settings.go
T
john-okeefe d225e1dff4 feat(scanner): add directory mtime-based fast polling for container environments
Podman rootless containers with overlay storage do not propagate inotify
events through bind mounts, making the fsnotify file watcher ineffective.
This caused new files added on the host to go undetected until the
5-minute full-filesystem-walk polling fallback caught them.

Add a lightweight directory mtime polling mechanism that runs every 10
seconds, checking stat() on all subdirectories under watched library
folders against a cached mtime value. When a directory's mtime changes
(indicating files were added/removed/renamed), it feeds into the existing
markDirectoryDirty() → processDirtyDirectories() → job queue pipeline.

Changes:
- Add dirMtimes cache + mutex to MediaScanner struct
- Add seedDirectoryMtimes() to populate cache on startup (prevents
  false-positive flood on first poll)
- Add pollDirectoryChanges() goroutine (10s ticker) and
  checkDirectoryMtimes() (walks directories, compares mtimes)
- Launch mtime poller from WatchChanges() alongside existing goroutines
- Rename StartPolling logs to [ORPHAN-CLEANUP] to clarify its role
- Change default poll interval from 60s → 30m (new file detection now
  handled by the fast mtime poll; full sync focuses on orphan cleanup)
- Update GetScanSettings default from 60 → 1800 seconds
- Add 5 tests: seed cache, skip nonexistent, detect new dir, skip
  unchanged, detect modified dir

Expected result: new files detected in ~20 seconds (10s poll + 10s
debounce) regardless of inotify/container support.
2026-05-12 16:54:35 -04:00

136 lines
4.4 KiB
Go

package handlers
import (
"bookhoard/internal/database"
"errors"
"net/http"
"strconv"
"time"
"github.com/jackc/pgx/v5"
"github.com/labstack/echo/v5"
)
type SystemSettingsHandler struct {
db *database.Queries
}
func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler {
return &SystemSettingsHandler{
db: db,
}
}
type UpdateScanSettingsRequest struct {
ScanPollIntervalSeconds int32 `json:"scan_poll_interval_seconds" validate:"required,min=1,max=3600"`
AutoScanEnabled bool `json:"auto_scan_enabled"`
}
type ScanSettingsResponse struct {
ScanPollIntervalSeconds int32 `json:"scan_poll_interval_seconds"`
AutoScanEnabled bool `json:"auto_scan_enabled"`
Message string `json:"message,omitempty"`
}
type UpdateTimezoneSettingsRequest struct {
DefaultTimezone string `json:"default_timezone" validate:"required"`
}
func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
var req UpdateTimezoneSettingsRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
if _, err := time.LoadLocation(req.DefaultTimezone); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid timezone"})
}
err := h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{
SettingKey: "default_timezone",
SettingValue: req.DefaultTimezone,
})
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
}
func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
var req UpdateScanSettingsRequest
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()})
}
scanFrequencyValue := strconv.FormatInt(int64(req.ScanPollIntervalSeconds), 10)
autoScanValue := strconv.FormatBool(req.AutoScanEnabled)
err := h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{
SettingKey: "scan_poll_interval_seconds",
SettingValue: scanFrequencyValue,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "system setting not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
err = h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{
SettingKey: "auto_scan_enabled",
SettingValue: autoScanValue,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "system setting not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, ScanSettingsResponse{
ScanPollIntervalSeconds: req.ScanPollIntervalSeconds,
AutoScanEnabled: req.AutoScanEnabled,
Message: "scan settings updated successfully",
})
}
func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error {
scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds")
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusOK, ScanSettingsResponse{
ScanPollIntervalSeconds: 1800,
AutoScanEnabled: true,
})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
autoScanSetting, err := h.db.GetSystemSetting(c.Request().Context(), "auto_scan_enabled")
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusOK, ScanSettingsResponse{
ScanPollIntervalSeconds: 1800,
AutoScanEnabled: true,
})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
scanFrequency, err := strconv.ParseInt(scanFrequencySetting, 10, 32)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "invalid scan frequency setting"})
}
autoScanEnabled, err := strconv.ParseBool(autoScanSetting)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "invalid auto scan setting"})
}
return c.JSON(http.StatusOK, ScanSettingsResponse{
ScanPollIntervalSeconds: int32(scanFrequency),
AutoScanEnabled: autoScanEnabled,
})
}