diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-06 10:16:14 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-06 10:16:14 +0300 |
| commit | 05bddac137607102f12c1c464db34a1e10707af6 (patch) | |
| tree | 4b7b74102f78e146b17c1e73370bb0bf4c42a11a /internal/processor | |
| parent | 51a8eaf8b759d6ef93c93b3e5430954959ea2aad (diff) | |
refactor: decompose Processor god object into focused files (SRP)
Extract audio coordination (voice selection, config assembly, attribution
writing) into audio_coordinator.go, card directory management into
card_store.go, and image downloading/searcher construction into
image_downloader.go. processor.go shrinks from ~1119 to ~575 lines,
each file now has a single clear responsibility. Also apply go fmt to
all touched files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/processor')
| -rw-r--r-- | internal/processor/audio_coordinator.go | 411 | ||||
| -rw-r--r-- | internal/processor/card_store.go | 149 | ||||
| -rw-r--r-- | internal/processor/image_downloader.go | 188 | ||||
| -rw-r--r-- | internal/processor/processor.go | 945 |
4 files changed, 950 insertions, 743 deletions
diff --git a/internal/processor/audio_coordinator.go b/internal/processor/audio_coordinator.go new file mode 100644 index 0000000..7153c98 --- /dev/null +++ b/internal/processor/audio_coordinator.go @@ -0,0 +1,411 @@ +package processor + +// AudioCoordinator assembles audio provider configurations, selects voices, +// generates audio files, and writes attribution/metadata sidecars. It holds +// the audio-related viperCfg fields and flag references so that the main +// Processor struct does not need to deal with audio details directly. +// +// All methods are on *Processor rather than a separate struct to avoid an +// extra layer of indirection while still keeping the concerns separated into +// their own file (SRP at the file level, as recommended for Go packages). + +import ( + "context" + "fmt" + "math/rand" + "os" + "path/filepath" + "strings" + "time" + + "codeberg.org/snonux/totalrecall/internal/audio" + "codeberg.org/snonux/totalrecall/internal/cli" +) + +// audioProviderName returns the configured audio provider name, preferring +// the viper config value over the CLI flag so config-file settings win. +func (p *Processor) audioProviderName() string { + if p.viperCfg.audioProvider != "" { + return p.viperCfg.audioProvider + } + if p != nil && p.flags != nil { + return strings.ToLower(strings.TrimSpace(p.flags.AudioProvider)) + } + return "" +} + +// effectiveAudioFormat resolves the audio format from flags and config, with +// CLI flag taking precedence, then viper config, then provider-specific defaults. +func (p *Processor) effectiveAudioFormat() string { + if p != nil && p.flags != nil && p.flags.AudioFormatSpecified { + if format := strings.ToLower(strings.TrimSpace(p.flags.AudioFormat)); format != "" { + return format + } + } + + if p.viperCfg.audioFormatSet && p.viperCfg.audioFormat != "" { + return p.viperCfg.audioFormat + } + + if p != nil && p.flags != nil { + if format := strings.ToLower(strings.TrimSpace(p.flags.AudioFormat)); format != "" { + return format + } + } + + if p.audioProviderName() == "gemini" { + return audio.DefaultProviderConfig().OutputFormat + } + + return "mp3" +} + +// geminiTTSModel returns the Gemini TTS model, preferring viper config over CLI flag. +func (p *Processor) geminiTTSModel() string { + if p.viperCfg.geminiTTSModel != "" { + return p.viperCfg.geminiTTSModel + } + if p != nil && p.flags != nil { + return strings.TrimSpace(p.flags.GeminiTTSModel) + } + return "" +} + +// geminiVoice returns the Gemini voice, preferring viper config over CLI flag. +func (p *Processor) geminiVoice() string { + if p.viperCfg.geminiVoice != "" { + return p.viperCfg.geminiVoice + } + if p != nil && p.flags != nil { + return strings.TrimSpace(p.flags.GeminiVoice) + } + return "" +} + +// openAIVoice returns the OpenAI voice, preferring viper config over CLI flag. +func (p *Processor) openAIVoice() string { + if p.viperCfg.openAIVoice != "" { + return p.viperCfg.openAIVoice + } + if p != nil && p.flags != nil { + return strings.TrimSpace(p.flags.OpenAIVoice) + } + return "" +} + +// audioVoicesForProvider returns all available voices for the configured provider +// without requiring a Provider instance (uses the package-level VoicesFor helper). +func (p *Processor) audioVoicesForProvider() []string { + return audio.VoicesFor(p.audioProviderName()) +} + +// audioVoiceForProvider selects a single voice for the configured provider. +// If a specific voice is configured, it is returned; otherwise a random voice +// from the provider's list is chosen using the injected randomIntn function. +func (p *Processor) audioVoiceForProvider() string { + switch p.audioProviderName() { + case "gemini": + if voice := p.geminiVoice(); voice != "" { + return voice + } + voices := p.audioVoicesForProvider() + if p.randomIntn != nil { + return voices[p.randomIntn(len(voices))] + } + return voices[rand.Intn(len(voices))] + default: + if voice := p.openAIVoice(); voice != "" { + return voice + } + voices := p.audioVoicesForProvider() + if p.randomIntn != nil { + return voices[p.randomIntn(len(voices))] + } + return voices[rand.Intn(len(voices))] + } +} + +// logSelectedAudioVoice prints which voice was selected and whether it was +// specified by the user or picked randomly. Used for informational output only. +func (p *Processor) logSelectedAudioVoice(provider, voice string) { + switch provider { + case "gemini": + if p.geminiVoice() != "" { + fmt.Printf(" Using specified Gemini voice: %s\n", voice) + } else { + fmt.Printf(" Using random Gemini voice: %s\n", voice) + } + default: + if p.openAIVoice() != "" { + fmt.Printf(" Using specified voice: %s\n", voice) + } else { + fmt.Printf(" Using random voice: %s\n", voice) + } + } +} + +// generateAudio generates audio files for a word using the configured provider. +// When AllVoices is set all provider voices are generated; otherwise a single +// voice is selected (with Gemini fallback retry on empty audio). +// ctx is threaded down to provider.GenerateAudio so the caller's deadline applies. +func (p *Processor) generateAudio(ctx context.Context, word string) error { + provider := p.audioProviderName() + + if p.flags.AllVoices { + return p.generateAudioForAllVoices(ctx, word) + } + + voice := p.audioVoiceForProvider() + p.logSelectedAudioVoice(provider, voice) + + // For Gemini with no explicit voice, use automatic fallback through all voices. + if provider == "gemini" && p.geminiVoice() == "" { + _, err := audio.RunWithVoiceFallbacks(voice, func(candidate string) error { + if candidate != voice { + fmt.Printf(" Retrying Gemini audio with voice: %s\n", candidate) + } + return p.generateAudioWithVoice(ctx, word, candidate) + }, func(candidate string) { + fmt.Printf(" Warning: Gemini returned no audio for voice %s\n", candidate) + }) + return err + } + + return p.generateAudioWithVoice(ctx, word, voice) +} + +// generateAudioForAllVoices iterates over every voice for the configured +// provider and generates a separate audio file for each. +func (p *Processor) generateAudioForAllVoices(ctx context.Context, word string) error { + voices := p.audioVoicesForProvider() + for i, voice := range voices { + fmt.Printf(" Generating audio %d/%d (voice: %s)...\n", i+1, len(voices), voice) + if err := p.generateAudioWithVoice(ctx, word, voice); err != nil { + return fmt.Errorf("failed to generate audio with voice %s: %w", voice, err) + } + } + return nil +} + +// generateAudioBgBg generates audio files for both sides of a bg-bg card. +// Both audio files are saved to the same directory as the front-word card. +// ctx is threaded down to provider.GenerateAudio so the caller's deadline applies. +func (p *Processor) generateAudioBgBg(ctx context.Context, front, back string) error { + provider := p.audioProviderName() + + voice := p.audioVoiceForProvider() + p.logSelectedAudioVoice(provider, voice) + + // Find or create the word directory ONCE (for the front word). + // Both audio files will be saved to this same directory. + wordDir := p.findOrCreateWordDirectory(front) + + generatePair := func(candidate string) error { + fmt.Printf(" Generating front audio for '%s'...\n", front) + if err := p.generateAudioWithVoiceAndFilenameInDir(ctx, front, candidate, "audio_front", wordDir); err != nil { + return fmt.Errorf("failed to generate front audio: %w", err) + } + + fmt.Printf(" Generating back audio for '%s'...\n", back) + if err := p.generateAudioWithVoiceAndFilenameInDir(ctx, back, candidate, "audio_back", wordDir); err != nil { + return fmt.Errorf("failed to generate back audio: %w", err) + } + + return nil + } + + // Use automatic fallback through all Gemini voices when no voice is pinned. + if provider == "gemini" && p.geminiVoice() == "" { + _, err := audio.RunWithVoiceFallbacks(voice, func(candidate string) error { + if candidate != voice { + fmt.Printf(" Retrying Gemini audio with voice: %s\n", candidate) + } + return generatePair(candidate) + }, func(candidate string) { + fmt.Printf(" Warning: Gemini returned no audio for voice %s\n", candidate) + }) + return err + } + + return generatePair(voice) +} + +// generateAudioWithVoice generates audio for a word with a specific voice, +// saving to the standard "audio" filename in the word's card directory. +func (p *Processor) generateAudioWithVoice(ctx context.Context, word, voice string) error { + return p.generateAudioWithVoiceAndFilename(ctx, word, voice, "audio") +} + +// generateAudioWithVoiceAndFilename generates audio and saves it using the +// given base filename (without extension) inside the word's card directory. +func (p *Processor) generateAudioWithVoiceAndFilename(ctx context.Context, word, voice, filenameBase string) error { + wordDir := p.findOrCreateWordDirectory(word) + return p.generateAudioWithVoiceAndFilenameInDir(ctx, word, voice, filenameBase, wordDir) +} + +// generateAudioWithVoiceAndFilenameInDir is the core audio generation method. +// It assembles the provider config, creates the provider, runs TTS, and writes +// the audio file plus its attribution/metadata sidecars to wordDir. +// ctx is passed directly to provider.GenerateAudio so the caller's deadline applies. +func (p *Processor) generateAudioWithVoiceAndFilenameInDir(ctx context.Context, word, voice, filenameBase, wordDir string) error { + providerConfig := p.buildAudioProviderConfig(voice) + + provider, err := p.newAudioProvider(providerConfig) + if err != nil { + return err + } + + outputFile := p.buildAudioOutputPath(wordDir, filenameBase, voice, providerConfig.OutputFormat) + + if err := provider.GenerateAudio(ctx, word, outputFile); err != nil { + return err + } + + // Write attribution and metadata sidecars next to the audio file. + if err := p.saveAudioAttribution(word, outputFile, providerConfig); err != nil { + fmt.Printf(" Warning: Failed to save audio attribution: %v\n", err) + } + + return nil +} + +// buildAudioProviderConfig assembles an audio.Config from flags and viper +// config. The voice argument is the already-resolved voice string for this call. +func (p *Processor) buildAudioProviderConfig(voice string) *audio.Config { + audioProvider := p.audioProviderName() + audioFormat := p.effectiveAudioFormat() + + // Generate random speed between 0.90 and 1.00 if not explicitly set. + speed := p.flags.OpenAISpeed + if audioProvider == "openai" && p.flags.OpenAISpeed == 0.9 && !p.viperCfg.openAISpeedSet { + speed = 0.90 + rand.Float64()*0.10 + } + + providerConfig := audio.DefaultProviderConfig() + providerConfig.Provider = audioProvider + providerConfig.OutputDir = p.flags.OutputDir + providerConfig.OpenAIKey = cli.GetOpenAIKey() + providerConfig.GoogleAPIKey = cli.GetGoogleAPIKey() + + switch audioProvider { + case "gemini": + providerConfig.OutputFormat = audioFormat + providerConfig.GeminiTTSModel = p.geminiTTSModel() + if voice != "" { + providerConfig.GeminiVoice = voice + } else { + providerConfig.GeminiVoice = p.geminiVoice() + } + providerConfig.GeminiSpeed = 1.0 + default: + p.applyOpenAIAudioConfig(providerConfig, voice, speed, audioFormat) + } + + return providerConfig +} + +// applyOpenAIAudioConfig populates the OpenAI-specific fields of providerConfig, +// applying viper overrides where the flag value still equals its default. +func (p *Processor) applyOpenAIAudioConfig(providerConfig *audio.Config, voice string, speed float64, audioFormat string) { + providerConfig.OutputFormat = audioFormat + providerConfig.OpenAIModel = p.flags.OpenAIModel + providerConfig.OpenAIVoice = voice + providerConfig.OpenAISpeed = speed + providerConfig.OpenAIInstruction = p.flags.OpenAIInstruction + + // Override with config-file values when the CLI flag is still at its default. + if p.flags.OpenAIModel == "gpt-4o-mini-tts" && p.viperCfg.openAIModelSet { + providerConfig.OpenAIModel = p.viperCfg.openAIModel + } + if p.flags.OpenAISpeed == 0.9 && p.viperCfg.openAISpeedSet { + providerConfig.OpenAISpeed = p.viperCfg.openAISpeed + } + if p.flags.OpenAIInstruction == "" && p.viperCfg.openAIInstructionSet { + providerConfig.OpenAIInstruction = p.viperCfg.openAIInstruction + } +} + +// buildAudioOutputPath constructs the output file path for an audio file. +// When AllVoices is set and filenameBase is "audio", the voice name is embedded +// in the filename to keep each voice's file distinct. +func (p *Processor) buildAudioOutputPath(wordDir, filenameBase, voice, outputFormat string) string { + if p.flags.AllVoices && filenameBase == "audio" { + return filepath.Join(wordDir, fmt.Sprintf("%s_%s.%s", filenameBase, voice, outputFormat)) + } + return filepath.Join(wordDir, fmt.Sprintf("%s.%s", filenameBase, outputFormat)) +} + +// saveAudioAttribution writes two sidecar files next to the audio file: +// - <audioFile>.attribution.txt — human-readable attribution for the clip +// - audio_metadata.txt — machine-readable metadata for the GUI +func (p *Processor) saveAudioAttribution(word, audioFile string, config *audio.Config) error { + processedText := audio.ProcessedTextForProvider(config.Provider, word) + instruction := audio.InstructionForProvider(config.Provider, config) + + params := audio.AttributionParamsFrom(config, word, instruction, processedText, time.Now()) + attribution := audio.BuildAttributionFor(config.Provider, params) + + attrPath := audio.AttributionPath(audioFile) + if err := os.WriteFile(attrPath, []byte(attribution), 0644); err != nil { + return fmt.Errorf("failed to write audio attribution file: %w", err) + } + + // Also save metadata for GUI display (non-fatal on failure). + wordDir := filepath.Dir(audioFile) + metadataFile := filepath.Join(wordDir, "audio_metadata.txt") + metadata := p.buildAudioMetadata(config, audioFile) + if err := os.WriteFile(metadataFile, []byte(metadata), 0644); err != nil { + fmt.Printf("Warning: Failed to save audio metadata: %v\n", err) + } + + return nil +} + +// buildAudioMetadata constructs the sidecar metadata string for the given +// audio file, resolving front/back file hints for bg-bg cards. +func (p *Processor) buildAudioMetadata(config *audio.Config, audioFile string) string { + audioFileHint, audioFileBackHint := p.audioMetadataFileHints(audioFile) + return audio.BuildSidecarMetadata(audio.SidecarMetadataParams{ + Provider: config.Provider, + OutputFormat: config.OutputFormat, + AudioFile: audioFileHint, + AudioFileBack: audioFileBackHint, + OpenAIModel: config.OpenAIModel, + OpenAIVoice: config.OpenAIVoice, + OpenAISpeed: config.OpenAISpeed, + OpenAIInstruction: config.OpenAIInstruction, + GeminiTTSModel: config.GeminiTTSModel, + GeminiVoice: config.GeminiVoice, + GeminiSpeed: config.GeminiSpeed, + }) +} + +// audioMetadataFileHints returns the (front, back) audio file path hints for +// the sidecar metadata. For a standard "audio" file the back hint is empty; +// for bg-bg front/back pairs both hints are returned when both files exist. +func (p *Processor) audioMetadataFileHints(audioFile string) (string, string) { + if strings.TrimSpace(audioFile) == "" { + return "", "" + } + + wordDir := filepath.Dir(audioFile) + base := filepath.Base(audioFile) + ext := filepath.Ext(base) + name := strings.TrimSuffix(base, ext) + + switch name { + case "audio": + return audioFile, "" + case "audio_front": + backFile := filepath.Join(wordDir, "audio_back"+ext) + if _, err := os.Stat(backFile); err == nil { + return audioFile, backFile + } + return audioFile, "" + case "audio_back": + frontFile := filepath.Join(wordDir, "audio_front"+ext) + return frontFile, audioFile + default: + return audioFile, "" + } +} diff --git a/internal/processor/card_store.go b/internal/processor/card_store.go new file mode 100644 index 0000000..1020998 --- /dev/null +++ b/internal/processor/card_store.go @@ -0,0 +1,149 @@ +package processor + +// CardStore manages the on-disk layout of word card directories. +// It wraps the low-level internal.FindCardDirectory / +// internal.FindOrCreateCardDirectory helpers and adds the higher-level +// isWordFullyProcessed check used by the batch processor to skip words that +// have already been completely generated. +// +// All methods are on *Processor rather than a separate struct to avoid an +// extra layer of indirection while still keeping the concerns separated into +// their own file (SRP at the file level, as recommended for Go packages). + +import ( + "os" + "path/filepath" + "strings" + + "codeberg.org/snonux/totalrecall/internal" + "codeberg.org/snonux/totalrecall/internal/anki" + "codeberg.org/snonux/totalrecall/internal/audio" +) + +// findOrCreateWordDirectory returns the existing card directory for word +// inside the configured output directory, creating it when absent. +func (p *Processor) findOrCreateWordDirectory(word string) string { + return internal.FindOrCreateCardDirectory(p.flags.OutputDir, word) +} + +// findCardDirectory searches the configured output directory for an existing +// card directory that contains the given word. Returns an empty string when +// no matching directory is found. +func (p *Processor) findCardDirectory(word string) string { + return internal.FindCardDirectory(p.flags.OutputDir, word) +} + +// isWordFullyProcessed returns true when the word's card directory already +// contains all expected output files (audio, image, translation, phonetic). +// The exact set of required files depends on the --skip-audio / --skip-images +// flags so partially-generated cards are still re-processed when relevant. +func (p *Processor) isWordFullyProcessed(word string) bool { + wordDir := p.findCardDirectory(word) + if wordDir == "" { + return false // No directory exists yet. + } + + // Base set of required files for every card type. + requiredFiles := []string{ + "word.txt", + "translation.txt", + "phonetic.txt", + } + + if !p.flags.SkipAudio { + if !p.hasRequiredAudioFiles(wordDir, &requiredFiles) { + return false + } + } + + if !p.flags.SkipImages { + if !p.hasRequiredImageFiles(wordDir, &requiredFiles) { + return false + } + } + + // Verify that every file in the required list actually exists on disk. + for _, file := range requiredFiles { + if _, err := os.Stat(filepath.Join(wordDir, file)); os.IsNotExist(err) { + return false + } + } + + return true +} + +// hasRequiredAudioFiles checks that all expected audio files and their +// attribution sidecars exist in wordDir. It appends extra filenames to +// requiredFiles as a side-effect so they are validated by the caller. +// Returns false as soon as a required audio file is determined to be missing. +func (p *Processor) hasRequiredAudioFiles(wordDir string, requiredFiles *[]string) bool { + cardType := internal.LoadCardType(wordDir) + audioFormat := p.effectiveAudioFormat() + + if cardType.IsBgBg() { + return p.hasBgBgAudioFiles(wordDir, audioFormat) + } + + return p.hasEnBgAudioFiles(wordDir, audioFormat, requiredFiles) +} + +// hasBgBgAudioFiles verifies that both audio_front and audio_back files exist +// along with their attribution sidecars. Used for bg-bg (definition) cards. +func (p *Processor) hasBgBgAudioFiles(wordDir, audioFormat string) bool { + frontAudioFiles := anki.ResolveAudioPaths(wordDir, "audio_front", audioFormat) + backAudioFiles := anki.ResolveAudioPaths(wordDir, "audio_back", audioFormat) + if len(frontAudioFiles) == 0 || len(backAudioFiles) == 0 { + return false + } + for _, audioFile := range append(frontAudioFiles, backAudioFiles...) { + if _, err := os.Stat(audio.AttributionPath(audioFile)); os.IsNotExist(err) { + return false + } + } + return true +} + +// hasEnBgAudioFiles verifies that at least one resolved audio file exists +// along with its attribution sidecar. Used for en-bg (translation) cards. +// It also appends "audio_metadata.txt" to requiredFiles so the caller checks it. +func (p *Processor) hasEnBgAudioFiles(wordDir, audioFormat string, requiredFiles *[]string) bool { + *requiredFiles = append(*requiredFiles, "audio_metadata.txt") + + audioFiles := anki.ResolveAudioPaths(wordDir, "audio", audioFormat) + if len(audioFiles) == 0 { + return false + } + for _, audioFile := range audioFiles { + if _, err := os.Stat(audio.AttributionPath(audioFile)); os.IsNotExist(err) { + return false + } + } + return true +} + +// hasRequiredImageFiles checks that at least one image file exists and that +// the expected image sidecar files are present. It appends those sidecar +// filenames to requiredFiles as a side-effect. +// Returns false when no image file can be found. +func (p *Processor) hasRequiredImageFiles(wordDir string, requiredFiles *[]string) bool { + *requiredFiles = append(*requiredFiles, + "image_attribution.txt", + "image_prompt.txt", + ) + + // Accept any of the common image extensions and naming conventions. + imagePatterns := []string{"image_*.jpg", "image_*.png", "image_*.webp", "image.jpg", "image.png", "image.webp"} + for _, pattern := range imagePatterns { + if strings.Contains(pattern, "*") { + matches, _ := filepath.Glob(filepath.Join(wordDir, pattern)) + if len(matches) > 0 { + return true + } + } else { + if _, err := os.Stat(filepath.Join(wordDir, pattern)); err == nil { + return true + } + } + } + return false +} diff --git a/internal/processor/image_downloader.go b/internal/processor/image_downloader.go new file mode 100644 index 0000000..c956c02 --- /dev/null +++ b/internal/processor/image_downloader.go @@ -0,0 +1,188 @@ +package processor + +// image_downloader.go delegates image downloading to the image package. +// It builds provider-specific ImageClient instances from flags/config and +// then calls image.Downloader to handle the actual HTTP download and +// attribution writing. This file implements the image-downloading concern +// so that processor.go can focus on the high-level word-processing flow. + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "codeberg.org/snonux/totalrecall/internal/cli" + "codeberg.org/snonux/totalrecall/internal/image" +) + +// downloadImagesWithTranslation downloads images for a word into its card +// directory. The translation is forwarded to the search options so that +// AI image providers can generate more contextually accurate images. +// ctx is passed to the image downloader so the caller's deadline applies. +func (p *Processor) downloadImagesWithTranslation(ctx context.Context, word, translationText string) error { + searcher, err := p.newImageSearcher() + if err != nil { + return err + } + + wordDir := p.findOrCreateWordDirectory(word) + + downloader := image.NewDownloader(searcher, &image.DownloadOptions{ + OutputDir: wordDir, + OverwriteExisting: true, + CreateDir: true, + FileNamePattern: "image", + MaxSizeBytes: 5 * 1024 * 1024, // 5 MB limit + }) + + searchOpts := image.DefaultSearchOptions(word) + if translationText != "" { + searchOpts.Translation = translationText + } + + // Register a prompt callback so the AI-generated prompt is persisted + // to disk before the download completes (used by the GUI and for debugging). + p.registerPromptCallback(searcher, wordDir) + + _, path, err := downloader.DownloadBestMatchWithOptions(ctx, searchOpts) + if err != nil { + return err + } + fmt.Printf(" Downloaded: %s\n", path) + + // Persist the final prompt used by the searcher (some providers set it + // only after the search call; this handles that case as a fallback). + p.saveImagePrompt(wordDir, searcher) + + return nil +} + +// registerPromptCallback wires a prompt-save callback into searchers that +// support SetPromptCallback. The callback fires during the Search call so the +// prompt is captured even if the subsequent download fails. +func (p *Processor) registerPromptCallback(searcher image.ImageClient, wordDir string) { + type promptSetter interface { + SetPromptCallback(func(prompt string)) + } + promptAware, ok := searcher.(promptSetter) + if !ok { + return + } + + promptFile := filepath.Join(wordDir, "image_prompt.txt") + promptAware.SetPromptCallback(func(prompt string) { + if prompt == "" { + return + } + if err := os.WriteFile(promptFile, []byte(prompt), 0644); err != nil { + fmt.Printf(" Warning: Failed to save image prompt: %v\n", err) + } + }) +} + +// saveImagePrompt persists the last prompt used by a searcher that implements +// GetLastPrompt. This acts as a fallback when the prompt is not available via +// the callback during the search call itself. +func (p *Processor) saveImagePrompt(wordDir string, searcher image.ImageClient) { + type promptGetter interface { + GetLastPrompt() string + } + + promptSource, ok := searcher.(promptGetter) + if !ok { + return + } + + usedPrompt := promptSource.GetLastPrompt() + if usedPrompt == "" { + return + } + + promptFile := filepath.Join(wordDir, "image_prompt.txt") + if err := os.WriteFile(promptFile, []byte(usedPrompt), 0644); err != nil { + fmt.Printf(" Warning: Failed to save image prompt: %v\n", err) + } +} + +// newImageSearcher creates the appropriate ImageClient based on the configured +// image provider (openai or nanobanana). +func (p *Processor) newImageSearcher() (image.ImageClient, error) { + switch p.imageProviderForRunMode() { + case "openai": + return p.newOpenAIImageSearcher() + case "nanobanana": + return p.newNanoBananaImageSearcher() + default: + return nil, fmt.Errorf("unknown image provider: %s", p.imageProviderForRunMode()) + } +} + +// imageProviderForRunMode resolves the image provider, giving precedence to +// the CLI flag when it was explicitly set, then the viper config value. +func (p *Processor) imageProviderForRunMode() string { + if p.flags.ImageAPISpecified { + return strings.ToLower(strings.TrimSpace(p.flags.ImageAPI)) + } + if p.viperCfg.imageProvider != "" { + return p.viperCfg.imageProvider + } + return strings.ToLower(strings.TrimSpace(p.flags.ImageAPI)) +} + +// newOpenAIImageSearcher builds an OpenAI ImageClient from flags and viper +// config. Config-file overrides are applied only when the flag still holds its +// default value so explicit CLI flags always win. +func (p *Processor) newOpenAIImageSearcher() (image.ImageClient, error) { + openaiConfig := &image.OpenAIConfig{ + APIKey: cli.GetOpenAIKey(), + Model: p.flags.OpenAIImageModel, + Size: p.flags.OpenAIImageSize, + Quality: p.flags.OpenAIImageQuality, + Style: p.flags.OpenAIImageStyle, + } + + // Apply viper overrides when CLI flag holds its zero/default value. + if p.flags.OpenAIImageModel == "dall-e-2" && p.viperCfg.imageOpenAIModelSet { + openaiConfig.Model = p.viperCfg.imageOpenAIModel + } + if p.flags.OpenAIImageSize == "512x512" && p.viperCfg.imageOpenAISizeSet { + openaiConfig.Size = p.viperCfg.imageOpenAISize + } + if p.flags.OpenAIImageQuality == "standard" && p.viperCfg.imageOpenAIQualitySet { + openaiConfig.Quality = p.viperCfg.imageOpenAIQuality + } + if p.flags.OpenAIImageStyle == "natural" && p.viperCfg.imageOpenAIStyleSet { + openaiConfig.Style = p.viperCfg.imageOpenAIStyle + } + + if openaiConfig.APIKey == "" { + return nil, fmt.Errorf("OpenAI API key is required for image generation") + } + + return p.newOpenAIImageClient(openaiConfig), nil +} + +// newNanoBananaImageSearcher builds a NanoBanana ImageClient from flags and +// viper config, applying overrides in the same flag-wins-over-config pattern. +func (p *Processor) newNanoBananaImageSearcher() (image.ImageClient, error) { + nanoBananaConfig := &image.NanoBananaConfig{ + APIKey: cli.GetGoogleAPIKey(), + Model: p.flags.NanoBananaModel, + TextModel: p.flags.NanoBananaTextModel, + } + + if !p.flags.NanoBananaModelSpecified && p.viperCfg.imageNanoBananaModelSet { + nanoBananaConfig.Model = p.viperCfg.imageNanoBananaModel + } + if !p.flags.NanoBananaTextModelSpecified && p.viperCfg.imageNanoBananaTextModelSet { + nanoBananaConfig.TextModel = p.viperCfg.imageNanoBananaTextModel + } + + if nanoBananaConfig.APIKey == "" { + return nil, fmt.Errorf("google API key is required for image generation") + } + + return p.newNanoBananaImageClient(nanoBananaConfig), nil +} diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 248bcc2..107fd12 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -99,6 +99,8 @@ func newViperConfig() viperConfig { } // Processor handles the main word processing logic. +// Audio coordination is in audio_coordinator.go, card directory management is +// in card_store.go, and image downloading is in image_downloader.go. // The factory fields (newOpenAIImageClient, newNanoBananaImageClient, newAudioProvider) // are injected at construction time so tests can swap them without mutating global state. type Processor struct { @@ -144,96 +146,117 @@ func NewProcessor(flags *cli.Flags) *Processor { } } -// ProcessBatch processes multiple words from a batch file +// ProcessBatch processes multiple words from a batch file. +// It first translates any entries that have English-to-Bulgarian translation +// needs, then validates all Bulgarian words, and finally processes each word +// with a per-word timeout to prevent a single hung API call from stalling the batch. func (p *Processor) ProcessBatch() error { entries, err := batch.ReadBatchFile(p.flags.BatchFile) if err != nil { return err } - // Create output directory (including parent directories) if err := os.MkdirAll(p.flags.OutputDir, 0755); err != nil { return fmt.Errorf("failed to create output directory: %w", err) } - // First pass: handle entries that need English to Bulgarian translation + if err := p.translateBatchEntries(entries); err != nil { + return err + } + + if err := p.validateBatchEntries(entries); err != nil { + return err + } + + skipped, processed, errCount := p.processBatchEntries(entries) + + p.printBatchSummary(len(entries), processed, skipped, errCount) + return nil +} + +// translateBatchEntries runs the first pass over entries that need English→Bulgarian +// translation and mutates the slice in place with the result. +func (p *Processor) translateBatchEntries(entries []batch.WordEntry) error { for i, entry := range entries { - if entry.NeedsTranslation && entry.Translation != "" { - // Translate English to Bulgarian - bulgarian, err := p.translator.TranslateEnglishToBulgarian(entry.Translation) - if err != nil { - fmt.Fprintf(os.Stderr, "Error translating '%s' to Bulgarian: %v\n", entry.Translation, err) - continue - } - entries[i].Bulgarian = bulgarian - fmt.Printf("Translated '%s' to Bulgarian: %s\n", entry.Translation, bulgarian) + if !entry.NeedsTranslation || entry.Translation == "" { + continue + } + bulgarian, err := p.translator.TranslateEnglishToBulgarian(entry.Translation) + if err != nil { + fmt.Fprintf(os.Stderr, "Error translating '%s' to Bulgarian: %v\n", entry.Translation, err) + continue } + entries[i].Bulgarian = bulgarian + fmt.Printf("Translated '%s' to Bulgarian: %s\n", entry.Translation, bulgarian) } + return nil +} - // Validate Bulgarian words +// validateBatchEntries checks that every entry with a Bulgarian word contains +// only valid Bulgarian text. Returns on the first validation failure. +func (p *Processor) validateBatchEntries(entries []batch.WordEntry) error { for _, entry := range entries { - if entry.Bulgarian != "" { - if err := audio.ValidateBulgarianText(entry.Bulgarian); err != nil { - return fmt.Errorf("invalid word '%s': %w", entry.Bulgarian, err) - } + if entry.Bulgarian == "" { + continue + } + if err := audio.ValidateBulgarianText(entry.Bulgarian); err != nil { + return fmt.Errorf("invalid word '%s': %w", entry.Bulgarian, err) } } + return nil +} - // Track statistics - skippedCount := 0 - processedCount := 0 - errorCount := 0 - - // Process each entry +// processBatchEntries iterates the validated entries and processes each word, +// skipping words that are already fully processed. Returns skip, process, and +// error counts for the summary. +func (p *Processor) processBatchEntries(entries []batch.WordEntry) (skipped, processed, errCount int) { for i, entry := range entries { if entry.Bulgarian == "" { - continue // Skip entries without Bulgarian word + continue } fmt.Printf("\nProcessing %d/%d: %s\n", i+1, len(entries), entry.Bulgarian) - // Check if word already exists and has all required files if p.isWordFullyProcessed(entry.Bulgarian) { wordDir := p.findCardDirectory(entry.Bulgarian) fmt.Printf(" ✓ Skipping '%s' - already fully processed in %s\n", entry.Bulgarian, filepath.Base(wordDir)) - skippedCount++ + skipped++ continue } - // Create a per-word timeout so a single hung API call cannot stall the - // whole batch. 5 minutes is generous for audio TTS + image download. + // Per-word timeout so a single hung API call cannot stall the whole batch. + // 5 minutes is generous for audio TTS + image download. wordCtx, wordCancel := context.WithTimeout(context.Background(), 5*time.Minute) err := p.ProcessWordWithTranslationAndType(wordCtx, entry.Bulgarian, entry.Translation, entry.CardType) wordCancel() // release resources even on success if err != nil { fmt.Fprintf(os.Stderr, "Error processing '%s': %v\n", entry.Bulgarian, err) - errorCount++ + errCount++ } else { - processedCount++ + processed++ } } + return +} - // Print summary +// printBatchSummary prints a human-readable summary of the batch run. +func (p *Processor) printBatchSummary(total, processed, skipped, errCount int) { fmt.Printf("\n=== Batch Processing Summary ===\n") - fmt.Printf("Total words: %d\n", len(entries)) - fmt.Printf("Processed: %d\n", processedCount) - fmt.Printf("Skipped (already complete): %d\n", skippedCount) - if errorCount > 0 { - fmt.Printf("Errors: %d\n", errorCount) + fmt.Printf("Total words: %d\n", total) + fmt.Printf("Processed: %d\n", processed) + fmt.Printf("Skipped (already complete): %d\n", skipped) + if errCount > 0 { + fmt.Printf("Errors: %d\n", errCount) } fmt.Printf("================================\n") - - return nil } -// ProcessSingleWord processes a single word from command line +// ProcessSingleWord validates and processes a single word from the command line. func (p *Processor) ProcessSingleWord(word string) error { - // Validate word if err := audio.ValidateBulgarianText(word); err != nil { return fmt.Errorf("invalid word '%s': %w", word, err) } - // Create output directory (including parent directories) if err := os.MkdirAll(p.flags.OutputDir, 0755); err != nil { return fmt.Errorf("failed to create output directory: %w", err) } @@ -242,64 +265,30 @@ func (p *Processor) ProcessSingleWord(word string) error { return p.ProcessWordWithTranslation(word, "") } -// ProcessWordWithTranslation processes a word with optional provided translation (en-bg mode) +// ProcessWordWithTranslation processes a word with an optional provided English +// translation, using the default en-bg card type. func (p *Processor) ProcessWordWithTranslation(word, providedTranslation string) error { return p.ProcessWordWithTranslationAndType(context.Background(), word, providedTranslation, internal.CardTypeEnBg) } -// ProcessWordWithTranslationAndType processes a word with optional provided translation and card type. -// ctx is used for all downstream API calls (audio TTS, image generation) so the caller can -// cancel or time out the whole operation. ProcessBatch passes a per-word deadline; callers -// t |
