feat: replace inotify watch path with fsnotify and clean up related tooling/docs (#204)

* Plan fsnotify migration and docs updates

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>

* Refactor watch mode to fsnotify and add agent docs

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>

* Fix errcheck violations in command and converter tests

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>

* Complete validation for fsnotify and lint fixes

Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Belphemur <197810+Belphemur@users.noreply.github.com>
This commit is contained in:
Copilot
2026-07-03 07:47:03 -04:00
committed by GitHub
co-authored by copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Belphemur
parent 18ff04c81f
commit b9170b14a1
12 changed files with 211 additions and 186 deletions
@@ -188,7 +188,7 @@ func setupTestCommand(t *testing.T) (*cobra.Command, func()) {
return &MockConverter{}, nil
}
cleanup := func() { converter.Get = originalGet }
// Set up the command
cmd := &cobra.Command{
Use: "optimize",
@@ -198,11 +198,11 @@ func setupTestCommand(t *testing.T) (*cobra.Command, func()) {
cmd.Flags().BoolP("override", "o", false, "Override the original CBZ/CBR files")
cmd.Flags().BoolP("split", "s", false, "Split long pages into smaller chunks")
cmd.Flags().DurationP("timeout", "t", 0, "Maximum time allowed for converting a single chapter")
// Reset converterType to default before test for consistency
converterType = constant.DefaultConversion
setupFormatFlag(cmd, &converterType, false)
return cmd, cleanup
}
@@ -219,8 +219,10 @@ func TestFormatFlagWithSpace(t *testing.T) {
defer cleanup()
// Test with space-separated format flag (--format webp)
cmd.ParseFlags([]string{"--format", "webp"})
if err := cmd.ParseFlags([]string{"--format", "webp"}); err != nil {
t.Fatalf("Failed to parse flags: %v", err)
}
// Execute the command
err = ConvertCbzCommand(cmd, []string{tempDir})
if err != nil {
@@ -246,8 +248,10 @@ func TestFormatFlagWithShortForm(t *testing.T) {
defer cleanup()
// Test with short form and space (-f webp)
cmd.ParseFlags([]string{"-f", "webp"})
if err := cmd.ParseFlags([]string{"-f", "webp"}); err != nil {
t.Fatalf("Failed to parse flags: %v", err)
}
// Execute the command
err = ConvertCbzCommand(cmd, []string{tempDir})
if err != nil {
@@ -273,8 +277,10 @@ func TestFormatFlagWithEquals(t *testing.T) {
defer cleanup()
// Test with equals syntax (--format=webp)
cmd.ParseFlags([]string{"--format=webp"})
if err := cmd.ParseFlags([]string{"--format=webp"}); err != nil {
t.Fatalf("Failed to parse flags: %v", err)
}
// Execute the command
err = ConvertCbzCommand(cmd, []string{tempDir})
if err != nil {
@@ -300,8 +306,10 @@ func TestFormatFlagDefaultValue(t *testing.T) {
defer cleanup()
// Don't set format flag - should use default
cmd.ParseFlags([]string{})
if err := cmd.ParseFlags([]string{}); err != nil {
t.Fatalf("Failed to parse flags: %v", err)
}
// Execute the command
err = ConvertCbzCommand(cmd, []string{tempDir})
if err != nil {
@@ -331,8 +339,10 @@ func TestFormatFlagCaseInsensitive(t *testing.T) {
defer cleanup()
// Test with different case variations
cmd.ParseFlags([]string{"--format", formatValue})
if err := cmd.ParseFlags([]string{"--format", formatValue}); err != nil {
t.Fatalf("Failed to parse flags: %v", err)
}
// Execute the command
err = ConvertCbzCommand(cmd, []string{tempDir})
if err != nil {
+10 -4
View File
@@ -59,7 +59,7 @@ func init() {
ef,
"log", "l",
"Set log level; can be 'panic', 'fatal', 'error', 'warn', 'info', 'debug', or 'trace'")
ef.RegisterCompletion(rootCmd, "log", enumflag.Help[zerolog.Level]{
if err := ef.RegisterCompletion(rootCmd, "log", enumflag.Help[zerolog.Level]{
zerolog.PanicLevel: "Only log panic messages",
zerolog.FatalLevel: "Log fatal and panic messages",
zerolog.ErrorLevel: "Log error, fatal, and panic messages",
@@ -67,11 +67,17 @@ func init() {
zerolog.InfoLevel: "Log info, warn, error, fatal, and panic messages",
zerolog.DebugLevel: "Log debug, info, warn, error, fatal, and panic messages",
zerolog.TraceLevel: "Log all messages including trace",
})
}); err != nil {
panic(fmt.Errorf("failed to register log completion: %w", err))
}
// Add log level environment variable support
viper.BindEnv("log", "LOG_LEVEL")
viper.BindPFlag("log", rootCmd.PersistentFlags().Lookup("log"))
if err := viper.BindEnv("log", "LOG_LEVEL"); err != nil {
panic(fmt.Errorf("failed to bind LOG_LEVEL env: %w", err))
}
if err := viper.BindPFlag("log", rootCmd.PersistentFlags().Lookup("log")); err != nil {
panic(fmt.Errorf("failed to bind log flag: %w", err))
}
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
ConfigureLogging()
+71 -58
View File
@@ -2,14 +2,16 @@ package commands
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
utils2 "github.com/belphemur/CBZOptimizer/v2/internal/utils"
"github.com/belphemur/CBZOptimizer/v2/pkg/converter"
"github.com/belphemur/CBZOptimizer/v2/pkg/converter/constant"
"github.com/pablodz/inotifywaitgo/inotifywaitgo"
"github.com/fsnotify/fsnotify"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
@@ -26,7 +28,7 @@ func init() {
RunE: WatchCommand,
Args: cobra.ExactArgs(1),
}
// Setup common flags (format, quality, override, split, timeout) with viper binding
setupCommonFlags(command, &converterType, 85, true, false, true)
@@ -65,69 +67,80 @@ func WatchCommand(_ *cobra.Command, args []string) error {
}
log.Info().Str("path", path).Bool("override", override).Uint8("quality", quality).Str("format", converterType.String()).Bool("split", split).Msg("Watching directory")
events := make(chan inotifywaitgo.FileEvent)
errors := make(chan error)
var wg sync.WaitGroup
watcher, err := fsnotify.NewWatcher()
if err != nil {
return fmt.Errorf("failed to create file watcher: %w", err)
}
defer watcher.Close()
wg.Add(1)
go func() {
defer wg.Done()
inotifywaitgo.WatchPath(&inotifywaitgo.Settings{
Dir: path,
FileEvents: events,
ErrorChan: errors,
Options: &inotifywaitgo.Options{
Recursive: true,
Events: []inotifywaitgo.EVENT{
inotifywaitgo.MOVE,
inotifywaitgo.CLOSE_WRITE,
},
Monitor: true,
},
Verbose: true,
})
}()
if err := addRecursiveWatch(watcher, path); err != nil {
return fmt.Errorf("failed to watch path %s: %w", path, err)
}
wg.Add(1)
go func() {
defer wg.Done()
for event := range events {
log.Debug().Str("file", event.Filename).Interface("events", event.Events).Msg("File event")
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return nil
}
log.Debug().Str("file", event.Name).Str("event", event.Op.String()).Msg("File event")
filename := strings.ToLower(event.Filename)
if !strings.HasSuffix(filename, ".cbz") && !strings.HasSuffix(filename, ".cbr") {
if event.Has(fsnotify.Create) {
fileInfo, err := os.Stat(event.Name)
if err == nil && fileInfo.IsDir() {
if err := addRecursiveWatch(watcher, event.Name); err != nil {
log.Error().Err(err).Str("path", event.Name).Msg("Failed to watch created directory")
}
}
}
if !shouldProcessWatchEvent(event) {
continue
}
for _, e := range event.Events {
switch e {
case inotifywaitgo.CLOSE_WRITE, inotifywaitgo.MOVE:
err := utils2.Optimize(&utils2.OptimizeOptions{
ChapterConverter: chapterConverter,
Path: event.Filename,
Quality: quality,
Override: override,
Split: split,
Timeout: timeout,
})
if err != nil {
errors <- fmt.Errorf("error processing file %s: %w", event.Filename, err)
}
default:
// ignored
}
if !isComicArchive(event.Name) {
continue
}
}
}()
wg.Add(1)
go func() {
defer wg.Done()
for err := range errors {
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")
}
case err, ok := <-watcher.Errors:
if !ok {
return nil
}
log.Error().Err(err).Msg("Watch error")
}
}()
wg.Wait()
return nil
}
}
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
}
if !entry.IsDir() {
return nil
}
if err := watcher.Add(path); err != nil {
return fmt.Errorf("failed to watch directory %s: %w", path, err)
}
return nil
})
}
func shouldProcessWatchEvent(event fsnotify.Event) bool {
return event.Has(fsnotify.Create) || event.Has(fsnotify.Write) || event.Has(fsnotify.Rename)
}
func isComicArchive(path string) bool {
filename := strings.ToLower(path)
return strings.HasSuffix(filename, ".cbz") || strings.HasSuffix(filename, ".cbr")
}