mirror of
https://github.com/Belphemur/CBZOptimizer.git
synced 2026-07-21 19:05:39 +02:00
fix(watch): debounce fsnotify events, backfill archives, resilient walk
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
This commit is contained in:
co-authored by
Belphemur
parent
e1e8ea92c1
commit
69c1a37577
@@ -7,6 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
utils2 "github.com/belphemur/CBZOptimizer/v2/internal/utils"
|
||||
"github.com/belphemur/CBZOptimizer/v2/pkg/converter"
|
||||
@@ -17,6 +19,16 @@ import (
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// debounceDelay is the quiet period we wait, per path, after the last fsnotify
|
||||
// event before triggering an optimization. This coalesces the bursts of
|
||||
// Write/Create/Rename events fsnotify emits while a file is still being
|
||||
// written (e.g. copied or downloaded into the watched folder).
|
||||
const debounceDelay = 2 * time.Second
|
||||
|
||||
// watchWorkerCount controls how many optimization jobs can run concurrently
|
||||
// so the fsnotify event loop is never blocked waiting on a conversion.
|
||||
var watchWorkerCount = runtime.NumCPU()
|
||||
|
||||
func init() {
|
||||
if runtime.GOOS != "linux" {
|
||||
return
|
||||
@@ -58,12 +70,12 @@ func WatchCommand(_ *cobra.Command, args []string) error {
|
||||
converterType := constant.FindConversionFormat(viper.GetString("format"))
|
||||
chapterConverter, err := converter.Get(converterType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get chapterConverter: %v", err)
|
||||
return fmt.Errorf("failed to get chapterConverter: %w", err)
|
||||
}
|
||||
|
||||
err = chapterConverter.PrepareConverter()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare converter: %v", err)
|
||||
return fmt.Errorf("failed to prepare converter: %w", err)
|
||||
}
|
||||
log.Info().Str("path", path).Bool("override", override).Uint8("quality", quality).Str("format", converterType.String()).Bool("split", split).Msg("Watching directory")
|
||||
|
||||
@@ -71,11 +83,28 @@ func WatchCommand(_ *cobra.Command, args []string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file watcher: %w", err)
|
||||
}
|
||||
defer watcher.Close()
|
||||
defer func() {
|
||||
if closeErr := watcher.Close(); closeErr != nil {
|
||||
log.Error().Err(closeErr).Msg("Failed to close file watcher")
|
||||
}
|
||||
}()
|
||||
|
||||
queue := newOptimizeQueue(watchWorkerCount, &utils2.OptimizeOptions{
|
||||
ChapterConverter: chapterConverter,
|
||||
Quality: quality,
|
||||
Override: override,
|
||||
Split: split,
|
||||
Timeout: timeout,
|
||||
})
|
||||
defer queue.Stop()
|
||||
|
||||
debouncer := newEventDebouncer(debounceDelay, queue.Enqueue)
|
||||
defer debouncer.Stop()
|
||||
|
||||
if err := addRecursiveWatch(watcher, path); err != nil {
|
||||
return fmt.Errorf("failed to watch path %s: %w", path, err)
|
||||
}
|
||||
backfillExistingArchives(path, debouncer.Trigger)
|
||||
|
||||
for {
|
||||
select {
|
||||
@@ -91,6 +120,11 @@ func WatchCommand(_ *cobra.Command, args []string) error {
|
||||
if err := addRecursiveWatch(watcher, event.Name); err != nil {
|
||||
log.Error().Err(err).Str("path", event.Name).Msg("Failed to watch created directory")
|
||||
}
|
||||
// The newly discovered directory may already contain
|
||||
// archives (e.g. a folder moved/copied in); back-fill them
|
||||
// since no further fsnotify event will target them.
|
||||
backfillExistingArchives(event.Name, debouncer.Trigger)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,16 +136,7 @@ func WatchCommand(_ *cobra.Command, args []string) error {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := utils2.Optimize(&utils2.OptimizeOptions{
|
||||
ChapterConverter: chapterConverter,
|
||||
Path: event.Name,
|
||||
Quality: quality,
|
||||
Override: override,
|
||||
Split: split,
|
||||
Timeout: timeout,
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Str("file", event.Name).Msg("Error processing file")
|
||||
}
|
||||
debouncer.Trigger(event.Name)
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -121,10 +146,15 @@ func WatchCommand(_ *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// addRecursiveWatch registers a watch on rootPath and every subdirectory
|
||||
// beneath it. Directories that can't be read (e.g. permission errors) are
|
||||
// logged and skipped instead of aborting the whole walk, so a single bad
|
||||
// subtree doesn't prevent the rest of the folder from being watched.
|
||||
func addRecursiveWatch(watcher *fsnotify.Watcher, rootPath string) error {
|
||||
return filepath.WalkDir(rootPath, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
log.Warn().Err(err).Str("path", path).Msg("Skipping path while setting up watch")
|
||||
return nil
|
||||
}
|
||||
if !entry.IsDir() {
|
||||
return nil
|
||||
@@ -136,6 +166,31 @@ func addRecursiveWatch(watcher *fsnotify.Watcher, rootPath string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// backfillExistingArchives walks rootPath for comic archives that already
|
||||
// exist on disk and hands them to process. This covers the case where a
|
||||
// directory (potentially already containing archives) is created/moved into
|
||||
// the watched tree: only the directory itself generates an fsnotify event,
|
||||
// so the files inside it would otherwise never be picked up.
|
||||
func backfillExistingArchives(rootPath string, process func(path string)) {
|
||||
err := filepath.WalkDir(rootPath, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("path", path).Msg("Skipping path while scanning for existing archives")
|
||||
return nil
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !isComicArchive(path) {
|
||||
return nil
|
||||
}
|
||||
process(path)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("path", rootPath).Msg("Failed to scan directory for existing archives")
|
||||
}
|
||||
}
|
||||
|
||||
func shouldProcessWatchEvent(event fsnotify.Event) bool {
|
||||
return event.Has(fsnotify.Create) || event.Has(fsnotify.Write) || event.Has(fsnotify.Rename)
|
||||
}
|
||||
@@ -144,3 +199,118 @@ func isComicArchive(path string) bool {
|
||||
filename := strings.ToLower(path)
|
||||
return strings.HasSuffix(filename, ".cbz") || strings.HasSuffix(filename, ".cbr")
|
||||
}
|
||||
|
||||
// eventDebouncer coalesces bursts of fsnotify events targeting the same path
|
||||
// into a single call to onQuiet, fired after the path has been quiet for
|
||||
// delay. This prevents repeated conversions being triggered while a file is
|
||||
// still being written, and avoids running Optimize against a Rename event
|
||||
// that reports the *old* (already gone) file name once the quiet period lets
|
||||
// us re-check the path.
|
||||
type eventDebouncer struct {
|
||||
delay time.Duration
|
||||
onQuiet func(path string)
|
||||
|
||||
mu sync.Mutex
|
||||
timers map[string]*time.Timer
|
||||
}
|
||||
|
||||
func newEventDebouncer(delay time.Duration, onQuiet func(path string)) *eventDebouncer {
|
||||
return &eventDebouncer{
|
||||
delay: delay,
|
||||
onQuiet: onQuiet,
|
||||
timers: make(map[string]*time.Timer),
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger (re)schedules onQuiet to run for path after the debounce delay,
|
||||
// resetting any previously pending timer for the same path.
|
||||
func (d *eventDebouncer) Trigger(path string) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if timer, exists := d.timers[path]; exists {
|
||||
timer.Reset(d.delay)
|
||||
return
|
||||
}
|
||||
|
||||
d.timers[path] = time.AfterFunc(d.delay, func() {
|
||||
d.mu.Lock()
|
||||
delete(d.timers, path)
|
||||
d.mu.Unlock()
|
||||
d.onQuiet(path)
|
||||
})
|
||||
}
|
||||
|
||||
// Stop cancels all pending timers.
|
||||
func (d *eventDebouncer) Stop() {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
for path, timer := range d.timers {
|
||||
timer.Stop()
|
||||
delete(d.timers, path)
|
||||
}
|
||||
}
|
||||
|
||||
// optimizeQueue is a small worker pool that runs Optimize jobs off of the
|
||||
// fsnotify event loop, so a slow conversion never blocks event draining.
|
||||
type optimizeQueue struct {
|
||||
jobs chan string
|
||||
wg sync.WaitGroup
|
||||
options *utils2.OptimizeOptions
|
||||
}
|
||||
|
||||
func newOptimizeQueue(workerCount int, options *utils2.OptimizeOptions) *optimizeQueue {
|
||||
if workerCount < 1 {
|
||||
workerCount = 1
|
||||
}
|
||||
q := &optimizeQueue{
|
||||
jobs: make(chan string, 64),
|
||||
options: options,
|
||||
}
|
||||
q.wg.Add(workerCount)
|
||||
for i := 0; i < workerCount; i++ {
|
||||
go q.worker()
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *optimizeQueue) worker() {
|
||||
defer q.wg.Done()
|
||||
for path := range q.jobs {
|
||||
q.process(path)
|
||||
}
|
||||
}
|
||||
|
||||
// process re-checks the path right before optimizing: fsnotify Rename events
|
||||
// commonly report the *old* file name (already gone by the time we act on
|
||||
// it), and Write can fire while a file is still being written elsewhere. In
|
||||
// override mode, Optimize may overwrite/delete the source file, so skipping
|
||||
// paths that no longer exist (or that are no longer regular files) avoids
|
||||
// noisy failures.
|
||||
func (q *optimizeQueue) process(path string) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("file", path).Msg("Skipping watch event: path no longer accessible")
|
||||
return
|
||||
}
|
||||
if info.IsDir() {
|
||||
return
|
||||
}
|
||||
|
||||
options := *q.options
|
||||
options.Path = path
|
||||
if err := utils2.Optimize(&options); err != nil {
|
||||
log.Error().Err(err).Str("file", path).Msg("Error processing file")
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue submits path for processing by the worker pool.
|
||||
func (q *optimizeQueue) Enqueue(path string) {
|
||||
q.jobs <- path
|
||||
}
|
||||
|
||||
// Stop closes the job queue and waits for in-flight jobs to finish.
|
||||
func (q *optimizeQueue) Stop() {
|
||||
close(q.jobs)
|
||||
q.wg.Wait()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user