diff --git a/README.md b/README.md index 5e16c3a..6ccdb36 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,16 @@ Watch a folder for new CBZ/CBR files and optimize them automatically: cbzconverter watch [folder] --quality 85 --override --format webp --split ``` +Watch mode only reacts to filesystem events that occur *after* it starts; by default it does +not scan and optimize files that already exist in the folder when it starts. Run the +`optimize` command first if you need to process an existing library, then use `watch` +to keep it up to date going forward, or pass `--backfill` to have `watch` optimize the +existing files at startup before it begins watching for new changes. The only exception +is a directory that gets created/moved into the watched tree while watch mode is +running: since no per-file event is emitted for files already inside it, its existing +archives are always processed once when the directory is first detected, regardless of +`--backfill`. + Or with Docker: ```sh @@ -94,6 +104,7 @@ docker run -v /path/to/comics:/comics ghcr.io/belphemur/cbzoptimizer:latest watc - Can be specified as: `--format webp`, `-f webp`, or `--format=webp` - Case-insensitive: `webp`, `WEBP`, and `WebP` are all valid - `--timeout`, `-t`: Maximum time allowed for converting a single chapter (e.g., 30s, 5m, 1h). 0 means no timeout. Default is 0. +- `--backfill`: *(`watch` only)* Optimize CBZ/CBR files that already exist in the watched folder at startup, before watching for new changes. Default is false. - `--log`, `-l`: Set log level; can be 'panic', 'fatal', 'error', 'warn', 'info', 'debug', or 'trace'. Default is info. ## Logging diff --git a/cmd/cbzoptimizer/commands/watch_command.go b/cmd/cbzoptimizer/commands/watch_command.go index b3bcb3e..3e82d8e 100644 --- a/cmd/cbzoptimizer/commands/watch_command.go +++ b/cmd/cbzoptimizer/commands/watch_command.go @@ -41,6 +41,9 @@ func init() { // Setup common flags (format, quality, override, split, timeout) with viper binding setupCommonFlags(command, &converterType, 85, true, false, true) + command.Flags().Bool("backfill", false, "Optimize CBZ/CBR files that already exist in the watched folder at startup, before watching for new changes") + _ = viper.BindPFlag("backfill", command.Flags().Lookup("backfill")) + AddCommand(command) } func WatchCommand(_ *cobra.Command, args []string) error { @@ -64,6 +67,8 @@ func WatchCommand(_ *cobra.Command, args []string) error { timeout := viper.GetDuration("timeout") + backfill := viper.GetBool("backfill") + converterType := constant.FindConversionFormat(viper.GetString("format")) chapterConverter, err := converter.Get(converterType) if err != nil { @@ -74,7 +79,7 @@ func WatchCommand(_ *cobra.Command, args []string) error { if err != nil { 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") + log.Info().Str("path", path).Bool("override", override).Uint8("quality", quality).Str("format", converterType.String()).Bool("split", split).Bool("backfill", backfill).Msg("Watching directory") watcher, err := fsnotify.NewWatcher() if err != nil { @@ -100,10 +105,17 @@ func WatchCommand(_ *cobra.Command, args []string) error { debouncer := newEventDebouncer(debounceDelay, queue.Enqueue) defer debouncer.Stop() + // Note: existing archives already present under path when the watch + // starts are left untouched unless --backfill is set. Watch mode only + // reacts to filesystem events going forward by default; use the + // `optimize` command (or pass --backfill) to process a library's + // existing contents. Archives inside a directory that is created/moved + // into the watched tree *after* startup are always back-filled below, + // since only the directory itself generates an fsnotify event. if err := addRecursiveWatch(watcher, path); err != nil { return fmt.Errorf("failed to watch path %s: %w", path, err) } - backfillExistingArchives(path, debouncer.Trigger) + maybeBackfillExistingArchives(backfill, path, debouncer.Trigger) for { select { @@ -194,6 +206,17 @@ func backfillExistingArchives(rootPath string, process func(path string)) { } } +// maybeBackfillExistingArchives runs backfillExistingArchives against +// rootPath only when enabled is true. It exists as its own function (rather +// than inlining the `if` check at the call site) so the gating decision used +// by WatchCommand can be exercised directly in tests. +func maybeBackfillExistingArchives(enabled bool, rootPath string, process func(path string)) { + if !enabled { + return + } + backfillExistingArchives(rootPath, process) +} + func shouldProcessWatchEvent(event fsnotify.Event) bool { return event.Has(fsnotify.Create) || event.Has(fsnotify.Write) || event.Has(fsnotify.Rename) } diff --git a/cmd/cbzoptimizer/commands/watch_command_test.go b/cmd/cbzoptimizer/commands/watch_command_test.go index 485c3fd..0484511 100644 --- a/cmd/cbzoptimizer/commands/watch_command_test.go +++ b/cmd/cbzoptimizer/commands/watch_command_test.go @@ -170,3 +170,35 @@ func TestOptimizeQueueSkipsMissingPath(t *testing.T) { assert.EqualValues(t, 1, atomic.LoadInt32(&calls)) } + +func TestWatchCommandBackfillFlagDefaultsToFalse(t *testing.T) { + watchCmd, _, err := rootCmd.Find([]string{"watch"}) + require.NoError(t, err) + + flag := watchCmd.Flags().Lookup("backfill") + require.NotNil(t, flag, "watch command should register a --backfill flag") + assert.Equal(t, "false", flag.DefValue) + assert.Equal(t, "bool", flag.Value.Type()) +} + +func TestMaybeBackfillExistingArchivesOnlyInvokedWhenRequested(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "chapter1.cbz"), []byte("data"), 0o644)) + + runBackfill := func(enabled bool) []string { + var found []string + var mu sync.Mutex + process := func(path string) { + mu.Lock() + defer mu.Unlock() + found = append(found, path) + } + // This is the exact same gating call WatchCommand makes based on the + // --backfill flag value. + maybeBackfillExistingArchives(enabled, root, process) + return found + } + + assert.Empty(t, runBackfill(false), "no pre-existing archive should be processed when backfill is disabled") + assert.Len(t, runBackfill(true), 1, "pre-existing archives should be processed when backfill is enabled") +} diff --git a/docs/project-overview.md b/docs/project-overview.md index 0a566c2..1531dfe 100644 --- a/docs/project-overview.md +++ b/docs/project-overview.md @@ -19,7 +19,7 @@ CBZOptimizer is a Go CLI that optimizes comic archives (`.cbz` and `.cbr`) by co ## Watch mode -`watch` monitors a directory tree for archive file changes and runs optimization automatically. +`watch` monitors a directory tree for archive file changes and runs optimization automatically. By default it only reacts to changes going forward; pass `--backfill` to also optimize archives that already exist in the folder at startup. ## Key runtime requirements