move feature from meta-sort to meta-core
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Debouncer delays events to ensure file stability
|
||||
type Debouncer struct {
|
||||
delay time.Duration
|
||||
pending map[string]*PendingEvent
|
||||
mu sync.Mutex
|
||||
callback func(FileEvent)
|
||||
stopped bool
|
||||
}
|
||||
|
||||
// NewDebouncer creates a new debouncer with the specified delay
|
||||
func NewDebouncer(delay time.Duration) *Debouncer {
|
||||
return &Debouncer{
|
||||
delay: delay,
|
||||
pending: make(map[string]*PendingEvent),
|
||||
}
|
||||
}
|
||||
|
||||
// SetCallback sets the callback function for debounced events
|
||||
func (d *Debouncer) SetCallback(cb func(FileEvent)) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.callback = cb
|
||||
}
|
||||
|
||||
// Add adds or updates an event in the debounce queue
|
||||
func (d *Debouncer) Add(event FileEvent) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.stopped {
|
||||
return
|
||||
}
|
||||
|
||||
key := event.Path
|
||||
|
||||
if existing, ok := d.pending[key]; ok {
|
||||
// Update existing pending event
|
||||
existing.LastSeen = time.Now()
|
||||
existing.Event = event // Update with latest event info
|
||||
|
||||
// Reset timer
|
||||
existing.Timer.Reset(d.delay)
|
||||
} else {
|
||||
// Create new pending event
|
||||
now := time.Now()
|
||||
pending := &PendingEvent{
|
||||
Event: event,
|
||||
FirstSeen: now,
|
||||
LastSeen: now,
|
||||
}
|
||||
|
||||
// Create timer
|
||||
pending.Timer = time.AfterFunc(d.delay, func() {
|
||||
d.flush(key)
|
||||
})
|
||||
|
||||
d.pending[key] = pending
|
||||
}
|
||||
}
|
||||
|
||||
// flush emits the event and removes it from pending
|
||||
func (d *Debouncer) flush(key string) {
|
||||
d.mu.Lock()
|
||||
|
||||
pending, ok := d.pending[key]
|
||||
if !ok {
|
||||
d.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if enough time has passed since last activity
|
||||
elapsed := time.Since(pending.LastSeen)
|
||||
if elapsed < d.delay {
|
||||
// Reset timer for remaining time
|
||||
remaining := d.delay - elapsed
|
||||
pending.Timer.Reset(remaining)
|
||||
d.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Remove from pending
|
||||
delete(d.pending, key)
|
||||
callback := d.callback
|
||||
event := pending.Event
|
||||
|
||||
d.mu.Unlock()
|
||||
|
||||
// Call callback outside of lock
|
||||
if callback != nil {
|
||||
callback(event)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the debouncer and cancels all pending events
|
||||
func (d *Debouncer) Stop() {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
d.stopped = true
|
||||
|
||||
// Cancel all timers
|
||||
for _, pending := range d.pending {
|
||||
pending.Timer.Stop()
|
||||
}
|
||||
|
||||
// Clear pending
|
||||
d.pending = make(map[string]*PendingEvent)
|
||||
}
|
||||
|
||||
// PendingCount returns the number of pending events
|
||||
func (d *Debouncer) PendingCount() int {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return len(d.pending)
|
||||
}
|
||||
|
||||
// FlushAll immediately flushes all pending events
|
||||
func (d *Debouncer) FlushAll() {
|
||||
d.mu.Lock()
|
||||
|
||||
keys := make([]string, 0, len(d.pending))
|
||||
for key := range d.pending {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
|
||||
d.mu.Unlock()
|
||||
|
||||
for _, key := range keys {
|
||||
d.flush(key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxRetries is the maximum number of delivery retries
|
||||
MaxRetries = 3
|
||||
// RetryDelay is the delay between retries
|
||||
RetryDelay = 5 * time.Second
|
||||
// WebhookTimeout is the timeout for webhook requests
|
||||
WebhookTimeout = 10 * time.Second
|
||||
// MaxFailCount is the max failures before removing subscriber
|
||||
MaxFailCount = 10
|
||||
)
|
||||
|
||||
// Dispatcher sends file events to subscribers
|
||||
type Dispatcher struct {
|
||||
subscribers map[string]*Subscriber
|
||||
sseClients map[chan FileEvent]bool
|
||||
mu sync.RWMutex
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewDispatcher creates a new event dispatcher
|
||||
func NewDispatcher() *Dispatcher {
|
||||
return &Dispatcher{
|
||||
subscribers: make(map[string]*Subscriber),
|
||||
sseClients: make(map[chan FileEvent]bool),
|
||||
httpClient: &http.Client{
|
||||
Timeout: WebhookTimeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe registers a webhook subscriber
|
||||
func (d *Dispatcher) Subscribe(url string, eventTypes []string) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
d.subscribers[url] = &Subscriber{
|
||||
URL: url,
|
||||
RegisteredAt: NowMS(),
|
||||
EventTypes: eventTypes,
|
||||
FailCount: 0,
|
||||
}
|
||||
|
||||
log.Printf("[Dispatcher] Subscribed webhook: %s", url)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unsubscribe removes a webhook subscriber
|
||||
func (d *Dispatcher) Unsubscribe(url string) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
delete(d.subscribers, url)
|
||||
log.Printf("[Dispatcher] Unsubscribed webhook: %s", url)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListSubscribers returns all subscribers
|
||||
func (d *Dispatcher) ListSubscribers() []Subscriber {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
result := make([]Subscriber, 0, len(d.subscribers))
|
||||
for _, sub := range d.subscribers {
|
||||
result = append(result, *sub)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// AddSSEClient adds an SSE client channel
|
||||
func (d *Dispatcher) AddSSEClient(ch chan FileEvent) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.sseClients[ch] = true
|
||||
}
|
||||
|
||||
// RemoveSSEClient removes an SSE client channel
|
||||
func (d *Dispatcher) RemoveSSEClient(ch chan FileEvent) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
delete(d.sseClients, ch)
|
||||
close(ch)
|
||||
}
|
||||
|
||||
// Dispatch sends an event to all subscribers
|
||||
func (d *Dispatcher) Dispatch(event FileEvent) {
|
||||
// Dispatch to webhooks
|
||||
go d.dispatchToWebhooks(event)
|
||||
|
||||
// Dispatch to SSE clients
|
||||
d.dispatchToSSE(event)
|
||||
}
|
||||
|
||||
// dispatchToWebhooks sends event to webhook subscribers
|
||||
func (d *Dispatcher) dispatchToWebhooks(event FileEvent) {
|
||||
d.mu.RLock()
|
||||
subscribers := make([]*Subscriber, 0, len(d.subscribers))
|
||||
for _, sub := range d.subscribers {
|
||||
subscribers = append(subscribers, sub)
|
||||
}
|
||||
d.mu.RUnlock()
|
||||
|
||||
for _, sub := range subscribers {
|
||||
// Check event type filter
|
||||
if len(sub.EventTypes) > 0 {
|
||||
found := false
|
||||
for _, et := range sub.EventTypes {
|
||||
if et == string(event.Type) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
go d.deliverToWebhook(sub.URL, event)
|
||||
}
|
||||
}
|
||||
|
||||
// deliverToWebhook delivers an event to a webhook with retries
|
||||
func (d *Dispatcher) deliverToWebhook(url string, event FileEvent) {
|
||||
body, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
log.Printf("[Dispatcher] Failed to marshal event: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < MaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(RetryDelay)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Event-Type", string(event.Type))
|
||||
|
||||
resp, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
// Success
|
||||
d.mu.Lock()
|
||||
if sub, ok := d.subscribers[url]; ok {
|
||||
sub.LastDelivery = NowMS()
|
||||
sub.FailCount = 0
|
||||
}
|
||||
d.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
lastErr = &httpError{StatusCode: resp.StatusCode}
|
||||
}
|
||||
|
||||
// All retries failed
|
||||
log.Printf("[Dispatcher] Failed to deliver to %s after %d retries: %v", url, MaxRetries, lastErr)
|
||||
|
||||
d.mu.Lock()
|
||||
if sub, ok := d.subscribers[url]; ok {
|
||||
sub.FailCount++
|
||||
if sub.FailCount >= MaxFailCount {
|
||||
delete(d.subscribers, url)
|
||||
log.Printf("[Dispatcher] Removed webhook %s after %d failures", url, MaxFailCount)
|
||||
}
|
||||
}
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
// dispatchToSSE sends event to SSE clients
|
||||
func (d *Dispatcher) dispatchToSSE(event FileEvent) {
|
||||
d.mu.RLock()
|
||||
clients := make([]chan FileEvent, 0, len(d.sseClients))
|
||||
for ch := range d.sseClients {
|
||||
clients = append(clients, ch)
|
||||
}
|
||||
d.mu.RUnlock()
|
||||
|
||||
for _, ch := range clients {
|
||||
select {
|
||||
case ch <- event:
|
||||
default:
|
||||
// Channel full, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SSEClientCount returns the number of connected SSE clients
|
||||
func (d *Dispatcher) SSEClientCount() int {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return len(d.sseClients)
|
||||
}
|
||||
|
||||
// httpError represents an HTTP error
|
||||
type httpError struct {
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (e *httpError) Error() string {
|
||||
return http.StatusText(e.StatusCode)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// Handlers provides HTTP handlers for watcher operations
|
||||
type Handlers struct {
|
||||
watcher *Watcher
|
||||
dispatcher *Dispatcher
|
||||
}
|
||||
|
||||
// NewHandlers creates new watcher handlers
|
||||
func NewHandlers(watcher *Watcher, dispatcher *Dispatcher) *Handlers {
|
||||
return &Handlers{
|
||||
watcher: watcher,
|
||||
dispatcher: dispatcher,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers all watcher-related routes
|
||||
func (h *Handlers) RegisterRoutes(r *mux.Router) {
|
||||
// Event subscriptions
|
||||
r.HandleFunc("/api/events/subscribe", h.handleSSESubscribe).Methods("GET")
|
||||
r.HandleFunc("/api/events/poll", h.handlePoll).Methods("GET")
|
||||
r.HandleFunc("/api/events/subscribers", h.handleListSubscribers).Methods("GET")
|
||||
r.HandleFunc("/api/events/subscribers", h.handleAddSubscriber).Methods("POST")
|
||||
r.HandleFunc("/api/events/subscribers/{url}", h.handleRemoveSubscriber).Methods("DELETE")
|
||||
|
||||
// Scan management
|
||||
r.HandleFunc("/api/scan/trigger", h.handleTriggerScan).Methods("POST")
|
||||
r.HandleFunc("/api/scan/status", h.handleScanStatus).Methods("GET")
|
||||
}
|
||||
|
||||
// handleSSESubscribe handles GET /api/events/subscribe (Server-Sent Events)
|
||||
func (h *Handlers) handleSSESubscribe(w http.ResponseWriter, r *http.Request) {
|
||||
// Set SSE headers
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
// Create event channel
|
||||
eventChan := make(chan FileEvent, 100)
|
||||
h.dispatcher.AddSSEClient(eventChan)
|
||||
|
||||
// Cleanup on disconnect
|
||||
defer h.dispatcher.RemoveSSEClient(eventChan)
|
||||
|
||||
// Get flusher
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "SSE not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Send initial connected message
|
||||
fmt.Fprintf(w, "event: connected\ndata: {\"status\":\"connected\"}\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
// Stream events
|
||||
for {
|
||||
select {
|
||||
case event := <-eventChan:
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "event: file\ndata: %s\n\n", string(data))
|
||||
flusher.Flush()
|
||||
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handlePoll handles GET /api/events/poll
|
||||
func (h *Handlers) handlePoll(w http.ResponseWriter, r *http.Request) {
|
||||
// Get since parameter
|
||||
sinceStr := r.URL.Query().Get("since")
|
||||
sinceMS := int64(0)
|
||||
if sinceStr != "" {
|
||||
if parsed, err := strconv.ParseInt(sinceStr, 10, 64); err == nil {
|
||||
sinceMS = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Get limit parameter
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 100
|
||||
if limitStr != "" {
|
||||
if parsed, err := strconv.Atoi(limitStr); err == nil {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
events := h.watcher.GetRecentEvents(sinceMS, limit)
|
||||
|
||||
writeJSON(w, http.StatusOK, EventsListResponse{
|
||||
Events: events,
|
||||
Count: len(events),
|
||||
})
|
||||
}
|
||||
|
||||
// handleListSubscribers handles GET /api/events/subscribers
|
||||
func (h *Handlers) handleListSubscribers(w http.ResponseWriter, r *http.Request) {
|
||||
subscribers := h.dispatcher.ListSubscribers()
|
||||
|
||||
writeJSON(w, http.StatusOK, SubscribersListResponse{
|
||||
Subscribers: subscribers,
|
||||
Count: len(subscribers),
|
||||
})
|
||||
}
|
||||
|
||||
// handleAddSubscriber handles POST /api/events/subscribers
|
||||
func (h *Handlers) handleAddSubscriber(w http.ResponseWriter, r *http.Request) {
|
||||
var req SubscribeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.URL == "" {
|
||||
writeError(w, http.StatusBadRequest, "URL is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.dispatcher.Subscribe(req.URL, req.EventTypes); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"message": "Subscribed",
|
||||
"url": req.URL,
|
||||
})
|
||||
}
|
||||
|
||||
// handleRemoveSubscriber handles DELETE /api/events/subscribers/{url}
|
||||
func (h *Handlers) handleRemoveSubscriber(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
url := vars["url"]
|
||||
|
||||
if url == "" {
|
||||
writeError(w, http.StatusBadRequest, "URL is required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.dispatcher.Unsubscribe(url); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"message": "Unsubscribed",
|
||||
})
|
||||
}
|
||||
|
||||
// handleTriggerScan handles POST /api/scan/trigger
|
||||
func (h *Handlers) handleTriggerScan(w http.ResponseWriter, r *http.Request) {
|
||||
go h.watcher.RunScan()
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"message": "Scan triggered",
|
||||
})
|
||||
}
|
||||
|
||||
// handleScanStatus handles GET /api/scan/status
|
||||
func (h *Handlers) handleScanStatus(w http.ResponseWriter, r *http.Request) {
|
||||
status := h.watcher.GetStatus()
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{
|
||||
"error": http.StatusText(status),
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package watcher
|
||||
|
||||
import "time"
|
||||
|
||||
// FileEventType represents the type of file event
|
||||
type FileEventType string
|
||||
|
||||
const (
|
||||
EventTypeAdd FileEventType = "add"
|
||||
EventTypeChange FileEventType = "change"
|
||||
EventTypeDelete FileEventType = "delete"
|
||||
EventTypeRename FileEventType = "rename"
|
||||
)
|
||||
|
||||
// FileEvent represents a file system event
|
||||
type FileEvent struct {
|
||||
Type FileEventType `json:"type"`
|
||||
Path string `json:"path"` // Relative to FILES_PATH
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
PartialHash string `json:"partialHash,omitempty"` // Hash of first 64KB
|
||||
OldPath string `json:"oldPath,omitempty"` // For rename events
|
||||
}
|
||||
|
||||
// Subscriber represents a webhook subscriber
|
||||
type Subscriber struct {
|
||||
URL string `json:"url"`
|
||||
RegisteredAt int64 `json:"registeredAt"`
|
||||
EventTypes []string `json:"eventTypes,omitempty"` // Empty means all
|
||||
LastDelivery int64 `json:"lastDelivery,omitempty"`
|
||||
FailCount int `json:"failCount"`
|
||||
}
|
||||
|
||||
// PendingEvent tracks a file event that's being debounced
|
||||
type PendingEvent struct {
|
||||
Event FileEvent
|
||||
FirstSeen time.Time
|
||||
LastSeen time.Time
|
||||
Timer *time.Timer
|
||||
}
|
||||
|
||||
// SubscribeRequest is the request to register a webhook
|
||||
type SubscribeRequest struct {
|
||||
URL string `json:"url"`
|
||||
EventTypes []string `json:"eventTypes,omitempty"`
|
||||
}
|
||||
|
||||
// EventsListResponse is the response for listing events
|
||||
type EventsListResponse struct {
|
||||
Events []FileEvent `json:"events"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// SubscribersListResponse is the response for listing subscribers
|
||||
type SubscribersListResponse struct {
|
||||
Subscribers []Subscriber `json:"subscribers"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// ScanStatusResponse is the response for scan status
|
||||
type ScanStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
Scanning bool `json:"scanning"`
|
||||
LastScan int64 `json:"lastScan,omitempty"`
|
||||
FileCount int `json:"fileCount,omitempty"`
|
||||
}
|
||||
|
||||
// NowMS returns current time in milliseconds
|
||||
func NowMS() int64 {
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/metazla/meta-core/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
// PartialHashSize is the number of bytes to hash for file identification
|
||||
PartialHashSize = 64 * 1024 // 64KB
|
||||
)
|
||||
|
||||
// Watcher monitors directories for file changes
|
||||
type Watcher struct {
|
||||
config *config.Config
|
||||
fsWatcher *fsnotify.Watcher
|
||||
debouncer *Debouncer
|
||||
dispatcher *Dispatcher
|
||||
filesPath string
|
||||
watchPaths []string
|
||||
|
||||
mu sync.RWMutex
|
||||
isRunning bool
|
||||
isScanning bool
|
||||
lastScan int64
|
||||
fileCount int
|
||||
stopChan chan struct{}
|
||||
eventBuffer []FileEvent
|
||||
}
|
||||
|
||||
// NewWatcher creates a new file watcher
|
||||
func NewWatcher(cfg *config.Config, dispatcher *Dispatcher) (*Watcher, error) {
|
||||
fsWatcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
debouncer := NewDebouncer(time.Duration(cfg.DebounceMS) * time.Millisecond)
|
||||
|
||||
w := &Watcher{
|
||||
config: cfg,
|
||||
fsWatcher: fsWatcher,
|
||||
debouncer: debouncer,
|
||||
dispatcher: dispatcher,
|
||||
filesPath: cfg.FilesPath,
|
||||
watchPaths: cfg.WatchFolderList,
|
||||
stopChan: make(chan struct{}),
|
||||
eventBuffer: make([]FileEvent, 0, 1000),
|
||||
}
|
||||
|
||||
// Set debouncer callback
|
||||
debouncer.SetCallback(func(event FileEvent) {
|
||||
w.handleDebouncedEvent(event)
|
||||
})
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// Start begins watching for file changes
|
||||
func (w *Watcher) Start() error {
|
||||
w.mu.Lock()
|
||||
if w.isRunning {
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
w.isRunning = true
|
||||
w.mu.Unlock()
|
||||
|
||||
// Add watch paths
|
||||
for _, watchPath := range w.watchPaths {
|
||||
if err := w.addWatchRecursive(watchPath); err != nil {
|
||||
log.Printf("[Watcher] Warning: failed to watch %s: %v", watchPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Start event processing goroutine
|
||||
go w.processEvents()
|
||||
|
||||
// Start initial scan
|
||||
go w.RunScan()
|
||||
|
||||
log.Printf("[Watcher] Started watching %d paths", len(w.watchPaths))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the watcher
|
||||
func (w *Watcher) Stop() error {
|
||||
w.mu.Lock()
|
||||
if !w.isRunning {
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
w.isRunning = false
|
||||
w.mu.Unlock()
|
||||
|
||||
close(w.stopChan)
|
||||
w.debouncer.Stop()
|
||||
return w.fsWatcher.Close()
|
||||
}
|
||||
|
||||
// addWatchRecursive adds a directory and all subdirectories to the watch list
|
||||
func (w *Watcher) addWatchRecursive(root string) error {
|
||||
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // Skip inaccessible paths
|
||||
}
|
||||
if info.IsDir() {
|
||||
if err := w.fsWatcher.Add(path); err != nil {
|
||||
log.Printf("[Watcher] Warning: cannot watch %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// processEvents handles fsnotify events
|
||||
func (w *Watcher) processEvents() {
|
||||
for {
|
||||
select {
|
||||
case <-w.stopChan:
|
||||
return
|
||||
|
||||
case event, ok := <-w.fsWatcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.handleFsEvent(event)
|
||||
|
||||
case err, ok := <-w.fsWatcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Printf("[Watcher] Error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleFsEvent converts an fsnotify event to a FileEvent
|
||||
func (w *Watcher) handleFsEvent(event fsnotify.Event) {
|
||||
// Get relative path
|
||||
relPath, err := filepath.Rel(w.filesPath, event.Name)
|
||||
if err != nil {
|
||||
relPath = event.Name
|
||||
}
|
||||
|
||||
// Clean up path
|
||||
relPath = filepath.ToSlash(relPath)
|
||||
|
||||
// Determine event type
|
||||
var eventType FileEventType
|
||||
switch {
|
||||
case event.Op&fsnotify.Create == fsnotify.Create:
|
||||
eventType = EventTypeAdd
|
||||
// If it's a new directory, add it to watch
|
||||
if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
|
||||
w.fsWatcher.Add(event.Name)
|
||||
}
|
||||
case event.Op&fsnotify.Write == fsnotify.Write:
|
||||
eventType = EventTypeChange
|
||||
case event.Op&fsnotify.Remove == fsnotify.Remove:
|
||||
eventType = EventTypeDelete
|
||||
case event.Op&fsnotify.Rename == fsnotify.Rename:
|
||||
eventType = EventTypeRename
|
||||
default:
|
||||
return // Ignore other events
|
||||
}
|
||||
|
||||
// Skip directories for file events (except remove)
|
||||
if eventType != EventTypeDelete {
|
||||
info, err := os.Stat(event.Name)
|
||||
if err == nil && info.IsDir() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get file info if available
|
||||
var size int64
|
||||
if eventType != EventTypeDelete {
|
||||
if info, err := os.Stat(event.Name); err == nil {
|
||||
size = info.Size()
|
||||
}
|
||||
}
|
||||
|
||||
fileEvent := FileEvent{
|
||||
Type: eventType,
|
||||
Path: relPath,
|
||||
Size: size,
|
||||
Timestamp: NowMS(),
|
||||
}
|
||||
|
||||
// Send to debouncer
|
||||
w.debouncer.Add(fileEvent)
|
||||
}
|
||||
|
||||
// handleDebouncedEvent processes a debounced event
|
||||
func (w *Watcher) handleDebouncedEvent(event FileEvent) {
|
||||
// Compute partial hash for add/change events
|
||||
if event.Type == EventTypeAdd || event.Type == EventTypeChange {
|
||||
fullPath := filepath.Join(w.filesPath, event.Path)
|
||||
if hash, err := computePartialHash(fullPath); err == nil {
|
||||
event.PartialHash = hash
|
||||
}
|
||||
}
|
||||
|
||||
// Add to buffer
|
||||
w.mu.Lock()
|
||||
w.eventBuffer = append(w.eventBuffer, event)
|
||||
// Keep buffer size reasonable
|
||||
if len(w.eventBuffer) > 10000 {
|
||||
w.eventBuffer = w.eventBuffer[len(w.eventBuffer)-5000:]
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
// Dispatch to subscribers
|
||||
w.dispatcher.Dispatch(event)
|
||||
|
||||
log.Printf("[Watcher] Event: %s %s", event.Type, event.Path)
|
||||
}
|
||||
|
||||
// RunScan performs a full directory scan
|
||||
func (w *Watcher) RunScan() {
|
||||
w.mu.Lock()
|
||||
if w.isScanning {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.isScanning = true
|
||||
w.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
w.mu.Lock()
|
||||
w.isScanning = false
|
||||
w.lastScan = NowMS()
|
||||
w.mu.Unlock()
|
||||
}()
|
||||
|
||||
log.Println("[Watcher] Starting directory scan...")
|
||||
|
||||
fileCount := 0
|
||||
for _, watchPath := range w.watchPaths {
|
||||
count := w.scanDirectory(watchPath)
|
||||
fileCount += count
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
w.fileCount = fileCount
|
||||
w.mu.Unlock()
|
||||
|
||||
log.Printf("[Watcher] Scan complete: %d files found", fileCount)
|
||||
}
|
||||
|
||||
// scanDirectory scans a directory and emits add events for files
|
||||
func (w *Watcher) scanDirectory(root string) int {
|
||||
count := 0
|
||||
|
||||
filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip directories
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip hidden files
|
||||
if strings.HasPrefix(filepath.Base(path), ".") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get relative path
|
||||
relPath, err := filepath.Rel(w.filesPath, path)
|
||||
if err != nil {
|
||||
relPath = path
|
||||
}
|
||||
relPath = filepath.ToSlash(relPath)
|
||||
|
||||
// Emit add event (through debouncer)
|
||||
event := FileEvent{
|
||||
Type: EventTypeAdd,
|
||||
Path: relPath,
|
||||
Size: info.Size(),
|
||||
Timestamp: NowMS(),
|
||||
}
|
||||
|
||||
// Compute partial hash
|
||||
if hash, err := computePartialHash(path); err == nil {
|
||||
event.PartialHash = hash
|
||||
}
|
||||
|
||||
// Dispatch directly (skip debouncer for scan)
|
||||
w.dispatcher.Dispatch(event)
|
||||
count++
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
// GetRecentEvents returns events since a given timestamp
|
||||
func (w *Watcher) GetRecentEvents(sinceMS int64, limit int) []FileEvent {
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
|
||||
result := make([]FileEvent, 0)
|
||||
for _, event := range w.eventBuffer {
|
||||
if event.Timestamp > sinceMS {
|
||||
result = append(result, event)
|
||||
if limit > 0 && len(result) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetStatus returns watcher status
|
||||
func (w *Watcher) GetStatus() ScanStatusResponse {
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
|
||||
status := "running"
|
||||
if !w.isRunning {
|
||||
status = "stopped"
|
||||
}
|
||||
|
||||
return ScanStatusResponse{
|
||||
Status: status,
|
||||
Scanning: w.isScanning,
|
||||
LastScan: w.lastScan,
|
||||
FileCount: w.fileCount,
|
||||
}
|
||||
}
|
||||
|
||||
// computePartialHash computes SHA-256 hash of first 64KB of a file
|
||||
func computePartialHash(path string) (string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hasher := sha256.New()
|
||||
buffer := make([]byte, PartialHashSize)
|
||||
n, err := file.Read(buffer)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hasher.Write(buffer[:n])
|
||||
return hex.EncodeToString(hasher.Sum(nil)), nil
|
||||
}
|
||||
Reference in New Issue
Block a user