• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

jeeftor / audiobook-organizer / 23274672949

19 Mar 2026 01:00AM UTC coverage: 31.215% (+0.07%) from 31.142%
23274672949

push

github

jeeftor
feat(gui): selection safety, book-grouped preview, skipped-file reporting

- Fix critical bug: GetBatchPreview with empty selectedIndices now returns
  empty list instead of all books (prevented "move all 538" foot-gun)
- ExecutionPreview: Books/Files toggle — groups flat-mode tracks by
  author+series into book-level rows with expand/collapse for file details
- ExecutionResults: show "Skipped (already moved or missing)" count so
  users understand why N-of-M files were processed on repeat runs
- Execute button: shows "Executing..." immediately on click; disabled while
  preview is loading or after click to prevent double-fires
- Remove silent clear of previewItems on GetBatchPreview error (preserved
  stale items rather than wiping them and passing [] to ExecuteFileOperations)
- Static import ExecuteFileOperations in App.tsx (was dynamic import)
- SettingsContext: single 500ms polling context shared across all panels
- ColoredPath: pill/badge styling per path segment (orange=author,
  cyan=series, green=title, blue=filename)
- MetadataEditor: apply same pill badges to Value column for mapped fields
- OutputPreviewSimple: use ColoredPath (DRY, removes duplicate inline logic)
- Layout dropdown in ExecutionPreview so layout can be tweaked at final review
- Re-scan on "Back to Editing" after execution so moved files disappear
- Remove auto-selection on open and on scan-mode change
- Add TDD test: GetBatchPreview empty-indices returns empty (not all books)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

3 of 56 new or added lines in 1 file covered. (5.36%)

330 existing lines in 4 files now uncovered.

3434 of 11001 relevant lines covered (31.22%)

0.35 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

41.15
/cmd/root.go
1
package cmd
2

3
import (
4
        "fmt"
5
        "os"
6
        "strings"
7

8
        "github.com/fatih/color"
9
        "github.com/jeeftor/audiobook-organizer/internal/organizer"
10
        "github.com/spf13/cobra"
11
        "github.com/spf13/viper"
12
)
13

14
// Constants for field names to avoid duplication
15
const ( // This makes sonar pass
16
        titleFieldKey      = "title-field"
17
        seriesFieldKey     = "series-field"
18
        authorFieldsKey    = "author-fields"
19
        trackFieldKey      = "track-field"
20
        discFieldKey       = "disc-field"
21
        useEmbeddedMetaKey = "use-embedded-metadata"
22
        removeEmptyKey     = "remove-empty"
23
        dryRunKey          = "dry-run"
24
)
25

26
var (
27
        inputDir            string // Combined input from --dir and --input
28
        outputDir           string // Combined output from --out and --output
29
        replaceSpace        string
30
        verbose             bool
31
        dryRun              bool
32
        undo                bool
33
        prompt              bool
34
        removeEmpty         bool
35
        useEmbeddedMetadata bool
36
        flat                bool
37
        skipErrors          bool
38
        layout              string // Directory structure layout
39

40
        // Field mapping flags
41
        titleField   string
42
        seriesField  string
43
        authorFields string // Comma-separated list
44
        trackField   string
45
        discField    string
46

47
        cfgFile string
48
)
49

50
// envAliases maps config keys to their possible environment variable names
51
var envAliases = map[string][]string{
52
        "dir":              {"AO_DIR", "AO_INPUT", "AUDIOBOOK_ORGANIZER_DIR", "AUDIOBOOK_ORGANIZER_INPUT"},
53
        "input":            {"AO_DIR", "AO_INPUT", "AUDIOBOOK_ORGANIZER_DIR", "AUDIOBOOK_ORGANIZER_INPUT"},
54
        "out":              {"AO_OUT", "AO_OUTPUT", "AUDIOBOOK_ORGANIZER_OUT", "AUDIOBOOK_ORGANIZER_OUTPUT"},
55
        "output":           {"AO_OUT", "AO_OUTPUT", "AUDIOBOOK_ORGANIZER_OUT", "AUDIOBOOK_ORGANIZER_OUTPUT"},
56
        "replace_space":    {"AO_REPLACE_SPACE", "AUDIOBOOK_ORGANIZER_REPLACE_SPACE"},
57
        "verbose":          {"AO_VERBOSE", "AUDIOBOOK_ORGANIZER_VERBOSE"},
58
        dryRunKey:          {"AO_DRY_RUN", "AUDIOBOOK_ORGANIZER_DRY_RUN"},
59
        "undo":             {"AO_UNDO", "AUDIOBOOK_ORGANIZER_UNDO"},
60
        "prompt":           {"AO_PROMPT", "AUDIOBOOK_ORGANIZER_PROMPT"},
61
        removeEmptyKey:     {"AO_REMOVE_EMPTY", "AUDIOBOOK_ORGANIZER_REMOVE_EMPTY"},
62
        useEmbeddedMetaKey: {"AO_USE_EMBEDDED_METADATA", "AUDIOBOOK_ORGANIZER_USE_EMBEDDED_METADATA"},
63
        "flat":             {"AO_FLAT", "AUDIOBOOK_ORGANIZER_FLAT"},
64
        "layout":           {"AO_LAYOUT", "AUDIOBOOK_ORGANIZER_LAYOUT"},
65

66
        // Field mapping environment variables
67
        titleFieldKey:   {"AO_TITLE_FIELD", "AUDIOBOOK_ORGANIZER_TITLE_FIELD"},
68
        seriesFieldKey:  {"AO_SERIES_FIELD", "AUDIOBOOK_ORGANIZER_SERIES_FIELD"},
69
        authorFieldsKey: {"AO_AUTHOR_FIELDS", "AUDIOBOOK_ORGANIZER_AUTHOR_FIELDS"},
70
        trackFieldKey:   {"AO_TRACK_FIELD", "AUDIOBOOK_ORGANIZER_TRACK_FIELD"},
71
        discFieldKey:    {"AO_DISC_FIELD", "AUDIOBOOK_ORGANIZER_DISC_FIELD"},
72

73
        // Rename command environment variables
74
        "rename-template":      {"AO_RENAME_TEMPLATE", "AUDIOBOOK_ORGANIZER_RENAME_TEMPLATE"},
75
        "rename-author-format": {"AO_RENAME_AUTHOR_FORMAT", "AUDIOBOOK_ORGANIZER_RENAME_AUTHOR_FORMAT"},
76
        "rename-recursive":     {"AO_RENAME_RECURSIVE", "AUDIOBOOK_ORGANIZER_RENAME_RECURSIVE"},
77
        "rename-strict":        {"AO_RENAME_STRICT", "AUDIOBOOK_ORGANIZER_RENAME_STRICT"},
78
        "rename-preserve-path": {"AO_RENAME_PRESERVE_PATH", "AUDIOBOOK_ORGANIZER_RENAME_PRESERVE_PATH"},
79
        "rename-prompt":        {"AO_RENAME_PROMPT", "AUDIOBOOK_ORGANIZER_RENAME_PROMPT"},
80
}
81

82
var rootCmd = &cobra.Command{
83
        Use:   "audiobook-organizer",
84
        Short: "Organize audiobooks based on metadata.json files",
85
        PreRun: func(cmd *cobra.Command, args []string) {
×
86
                // Store the original PreRun logic in a separate function
×
87
                handleInputAliases(cmd)
×
88
                // Handle input directory aliases
×
89
                if cmd.Flags().Changed("input") {
×
90
                        viper.Set("dir", viper.GetString("input"))
×
91
                } else if cmd.Flags().Changed("dir") {
×
92
                        viper.Set("input", viper.GetString("dir"))
×
UNCOV
93
                }
×
94

95
                // Handle output directory aliases
96
                if cmd.Flags().Changed("output") {
×
97
                        viper.Set("out", viper.GetString("output"))
×
98
                } else if cmd.Flags().Changed("out") {
×
99
                        viper.Set("output", viper.GetString("out"))
×
UNCOV
100
                }
×
101

102
                // If flat mode is enabled, automatically enable embedded metadata
103
                if viper.GetBool("flat") {
×
104
                        viper.Set(useEmbeddedMetaKey, true)
×
105
                        if viper.GetBool("verbose") {
×
106
                                color.Cyan("â„šī¸ Flat mode enabled: automatically using embedded metadata")
×
UNCOV
107
                        }
×
108
                }
109
        },
110
        Run: func(cmd *cobra.Command, args []string) {
×
111
                // Get the final input directory (either from --dir or --input)
×
112
                inputDir := viper.GetString("dir")
×
113
                if inputDir == "" {
×
114
                        inputDir = viper.GetString("input")
×
UNCOV
115
                }
×
116

117
                // Get the final output directory (either from --out or --output)
118
                outputDir := viper.GetString("out")
×
119
                if outputDir == "" {
×
120
                        outputDir = viper.GetString("output")
×
UNCOV
121
                }
×
122

123
                // Parse author fields from comma-separated string
124
                authorFieldsList := []string{}
×
125
                if af := viper.GetString(authorFieldsKey); af != "" {
×
126
                        authorFieldsList = strings.Split(af, ",")
×
UNCOV
127
                }
×
128

129
                org, err := organizer.NewOrganizer(
×
130
                        &organizer.OrganizerConfig{
×
131
                                BaseDir:             inputDir,
×
132
                                OutputDir:           outputDir,
×
133
                                ReplaceSpace:        viper.GetString("replace_space"),
×
134
                                Verbose:             viper.GetBool("verbose"),
×
135
                                DryRun:              viper.GetBool(dryRunKey),
×
136
                                Undo:                viper.GetBool("undo"),
×
137
                                Prompt:              viper.GetBool("prompt"),
×
138
                                RemoveEmpty:         viper.GetBool(removeEmptyKey),
×
139
                                UseEmbeddedMetadata: viper.GetBool(useEmbeddedMetaKey),
×
140
                                Flat:                viper.GetBool("flat"),
×
141
                                SkipErrors:          viper.GetBool("skip-errors"),
×
142
                                Layout:              viper.GetString("layout"),
×
143
                                FieldMapping: organizer.FieldMapping{
×
144
                                        TitleField:   viper.GetString(titleFieldKey),
×
145
                                        SeriesField:  viper.GetString(seriesFieldKey),
×
146
                                        AuthorFields: authorFieldsList,
×
147
                                        TrackField:   viper.GetString(trackFieldKey),
×
148
                                        DiscField:    viper.GetString(discFieldKey),
×
149
                                },
×
150
                        },
×
151
                )
×
152
                if err != nil {
×
153
                        organizer.PrintRed("Configuration error: %v", err)
×
UNCOV
154
                        os.Exit(1)
×
155
                }
×
156

157
                if err := org.Execute(); err != nil {
×
158
                        color.Red("❌ Error: %v", err)
×
UNCOV
159
                        os.Exit(1)
×
UNCOV
160
                }
×
161

162
                // Print log file location if not in dry-run mode
163
                if !viper.GetBool(dryRunKey) {
×
164
                        logPath := org.GetLogPath()
×
165
                        color.Cyan("\n📝 Log file location: %s", logPath)
×
166
                        color.Cyan("To undo these changes, run:")
×
167
                        color.White("  audiobook-organizer --input=%s --undo", inputDir)
×
168
                        if outputDir != "" {
×
169
                                color.White("  audiobook-organizer --input=%s --output=%s --undo",
×
UNCOV
170
                                        inputDir, outputDir)
×
UNCOV
171
                        }
×
172
                }
173
        },
174
}
175

176
func Execute() error {
×
177
        color.Cyan("🎧 Audiobook Organizer")
×
178
        color.Cyan("=====================")
×
UNCOV
179
        return rootCmd.Execute()
×
UNCOV
180
}
×
181

182
// getEnvValue checks all possible environment variable names for a config key
183
// handleInputAliases handles the aliasing between dir/input and out/output flags
184
func handleInputAliases(cmd *cobra.Command) {
×
185
        // Handle input directory aliases
×
186
        if cmd.Flags().Changed("input") {
×
187
                viper.Set("dir", viper.GetString("input"))
×
188
        } else if cmd.Flags().Changed("dir") {
×
UNCOV
189
                viper.Set("input", viper.GetString("dir"))
×
UNCOV
190
        }
×
191

192
        // Handle output directory aliases
193
        if cmd.Flags().Changed("output") {
×
194
                viper.Set("out", viper.GetString("output"))
×
195
        } else if cmd.Flags().Changed("out") {
×
UNCOV
196
                viper.Set("output", viper.GetString("out"))
×
UNCOV
197
        }
×
198
}
199

200
func getEnvValue(key string) string {
1✔
201
        if aliases, ok := envAliases[key]; ok {
2✔
202
                for _, alias := range aliases {
2✔
203
                        if value := os.Getenv(alias); value != "" {
1✔
UNCOV
204
                                return value
×
UNCOV
205
                        }
×
206
                }
207
        }
208
        return ""
1✔
209
}
210

211
func init() {
1✔
212
        cobra.OnInitialize(initConfig)
1✔
213

1✔
214
        // Config file flag
1✔
215
        rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.audiobook-organizer.yaml)")
1✔
216

1✔
217
        // Persistent flags (available to all subcommands)
1✔
218
        rootCmd.PersistentFlags().StringVar(&inputDir, "dir", "", "Base directory to scan (alias for --input)")
1✔
219
        rootCmd.PersistentFlags().StringVar(&inputDir, "input", "", "Base directory to scan (alias for --dir)")
1✔
220
        rootCmd.PersistentFlags().StringVar(&outputDir, "out", "", "Output directory (alias for --output)")
1✔
221
        rootCmd.PersistentFlags().StringVar(&outputDir, "output", "", "Output directory (alias for --out)")
1✔
222
        rootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "Verbose output")
1✔
223
        rootCmd.PersistentFlags().BoolVar(&dryRun, dryRunKey, false, "Show what would happen without making changes")
1✔
224
        rootCmd.PersistentFlags().BoolVar(&useEmbeddedMetadata, useEmbeddedMetaKey, false, "Use metadata embedded in EPUB files if metadata.json is not found")
1✔
225
        rootCmd.PersistentFlags().BoolVar(&flat, "flat", false, "Process files in a flat directory structure (automatically enables --use-embedded-metadata)")
1✔
226
        rootCmd.PersistentFlags().BoolVar(&skipErrors, "skip-errors", false, "Skip files with missing/invalid metadata instead of stopping")
1✔
227

1✔
228
        // Local flags (only for root command)
1✔
229
        rootCmd.Flags().StringVar(&replaceSpace, "replace_space", "", "Character to replace spaces")
1✔
230
        rootCmd.Flags().BoolVar(&undo, "undo", false, "Restore files to their original locations")
1✔
231
        rootCmd.Flags().BoolVar(&prompt, "prompt", false, "Prompt for confirmation before moving each book")
1✔
232
        rootCmd.Flags().BoolVar(&removeEmpty, removeEmptyKey, false, "Remove empty directories after moving files")
1✔
233
        rootCmd.Flags().StringVarP(&layout, "layout", "l", "author-series-title", "Directory structure layout:\n  - author-series-title:        Author/Series/Title/ (default)\n  - author-series-title-number: Author/Series/#1 - Title/ (include series number in title)\n  - author-title:               Author/Title/ (ignore series)\n  - author-only:                Author/ (flatten all books)")
1✔
234

1✔
235
        // Field mapping flags (persistent for all commands)
1✔
236
        rootCmd.PersistentFlags().StringVar(&titleField, titleFieldKey, "", "Field to use as title (e.g., 'album', 'title', 'track_title')")
1✔
237
        rootCmd.PersistentFlags().StringVar(&seriesField, seriesFieldKey, "", "Field to use as series (e.g., 'series', 'album')")
1✔
238
        rootCmd.PersistentFlags().StringVar(&authorFields, authorFieldsKey, "", "Comma-separated list of fields to try for author (e.g., 'authors,narrators,album_artist,artist')")
1✔
239
        rootCmd.PersistentFlags().StringVar(&trackField, trackFieldKey, "", "Field to use for track number (e.g., 'track', 'track_number', 'trck', 'trk')")
1✔
240
        rootCmd.PersistentFlags().StringVar(&discField, discFieldKey, "", "Field to use for disc number (e.g., 'disc', 'discnumber', 'disk', 'tpos')")
1✔
241

1✔
242
        // Bind persistent flags to viper
1✔
243
        viper.BindPFlag("dir", rootCmd.PersistentFlags().Lookup("dir"))
1✔
244
        viper.BindPFlag("input", rootCmd.PersistentFlags().Lookup("input"))
1✔
245
        viper.BindPFlag("out", rootCmd.PersistentFlags().Lookup("out"))
1✔
246
        viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output"))
1✔
247
        viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
1✔
248
        viper.BindPFlag(dryRunKey, rootCmd.PersistentFlags().Lookup(dryRunKey))
1✔
249
        viper.BindPFlag(useEmbeddedMetaKey, rootCmd.PersistentFlags().Lookup(useEmbeddedMetaKey))
1✔
250
        viper.BindPFlag("flat", rootCmd.PersistentFlags().Lookup("flat"))
1✔
251
        viper.BindPFlag("skip-errors", rootCmd.PersistentFlags().Lookup("skip-errors"))
1✔
252
        viper.BindPFlag(titleFieldKey, rootCmd.PersistentFlags().Lookup(titleFieldKey))
1✔
253
        viper.BindPFlag(seriesFieldKey, rootCmd.PersistentFlags().Lookup(seriesFieldKey))
1✔
254
        viper.BindPFlag(authorFieldsKey, rootCmd.PersistentFlags().Lookup(authorFieldsKey))
1✔
255
        viper.BindPFlag(trackFieldKey, rootCmd.PersistentFlags().Lookup(trackFieldKey))
1✔
256
        viper.BindPFlag(discFieldKey, rootCmd.PersistentFlags().Lookup(discFieldKey))
1✔
257

1✔
258
        // Bind local flags to viper
1✔
259
        viper.BindPFlag("replace_space", rootCmd.Flags().Lookup("replace_space"))
1✔
260
        viper.BindPFlag("undo", rootCmd.Flags().Lookup("undo"))
1✔
261
        viper.BindPFlag("prompt", rootCmd.Flags().Lookup("prompt"))
1✔
262
        viper.BindPFlag(removeEmptyKey, rootCmd.Flags().Lookup(removeEmptyKey))
1✔
263
        viper.BindPFlag("layout", rootCmd.Flags().Lookup("layout"))
1✔
264

1✔
265
        // Set up environment variable handling
1✔
266
        viper.SetEnvPrefix("AUDIOBOOK_ORGANIZER") // This will still be used for unmapped variables
1✔
267
        viper.AutomaticEnv()
1✔
268

1✔
269
        // Custom validation instead of using MarkFlagRequired
1✔
270
        // Only validate when running the root command itself, not subcommands
1✔
271
        rootCmd.PreRunE = func(cmd *cobra.Command, args []string) error {
1✔
272
                // Skip validation if a subcommand is being called
×
273
                // Check if we have any subcommands and if args indicate a subcommand
×
UNCOV
274
                if cmd.Name() != "audiobook-organizer" {
×
UNCOV
275
                        // This is a subcommand, skip root validation
×
276
                        return nil
×
277
                }
×
278

279
                // First run the existing PreRun function
UNCOV
280
                if cmd.PreRun != nil {
×
281
                        cmd.PreRun(cmd, args)
×
282
                }
×
283

284
                // Check if input directory is set via flags, env vars, or config file
UNCOV
285
                inputDir := viper.GetString("dir")
×
286
                if inputDir == "" {
×
287
                        inputDir = viper.GetString("input")
×
288
                }
×
289

UNCOV
290
                if inputDir == "" {
×
UNCOV
291
                        return fmt.Errorf("either --dir or --input must be specified")
×
UNCOV
292
                }
×
UNCOV
293
                return nil
×
294
        }
295
}
296

297
func initConfig() {
1✔
298
        if cfgFile != "" {
1✔
UNCOV
299
                // Use config file from the flag
×
UNCOV
300
                viper.SetConfigFile(cfgFile)
×
301
        } else {
1✔
302
                // Find home directory
1✔
303
                home, err := os.UserHomeDir()
1✔
304
                if err != nil {
1✔
UNCOV
305
                        fmt.Println(err)
×
UNCOV
306
                        os.Exit(1)
×
UNCOV
307
                }
×
308

309
                // Search config in home directory with name ".audiobook-organizer" (without extension)
310
                viper.AddConfigPath(home)
1✔
311
                viper.AddConfigPath(".")
1✔
312
                viper.SetConfigType("yaml")
1✔
313
                viper.SetConfigName(".audiobook-organizer")
1✔
314
        }
315

316
        // Read in environment variables that match
317
        viper.AutomaticEnv()
1✔
318

1✔
319
        // If a config file is found, read it in
1✔
320
        if err := viper.ReadInConfig(); err == nil {
1✔
UNCOV
321
                if viper.GetBool("verbose") {
×
UNCOV
322
                        color.Cyan("Using config file: %s", viper.ConfigFileUsed())
×
323
                }
×
324
        } else {
1✔
325
                // Only show error if a config file was explicitly specified
1✔
326
                if cfgFile != "" {
1✔
UNCOV
327
                        fmt.Fprintf(os.Stderr, "Error reading config file: %v\n", err)
×
UNCOV
328
                }
×
329
        }
330

331
        // Set up custom environment variable handling for our aliases
332
        // This needs to happen after config file is read but before validation
333
        for key := range envAliases {
2✔
334
                viper.RegisterAlias(key, strings.ToUpper(key))
1✔
335
                if value := getEnvValue(key); value != "" {
1✔
UNCOV
336
                        // Only set if not already set by config file or flags
×
UNCOV
337
                        if !viper.IsSet(key) {
×
UNCOV
338
                                viper.Set(key, value)
×
UNCOV
339
                        }
×
340
                }
341
        }
342
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc