summaryrefslogtreecommitdiff
path: root/internal/processor
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-06 10:16:14 +0300
committerPaul Buetow <paul@buetow.org>2026-04-06 10:16:14 +0300
commit05bddac137607102f12c1c464db34a1e10707af6 (patch)
tree4b7b74102f78e146b17c1e73370bb0bf4c42a11a /internal/processor
parent51a8eaf8b759d6ef93c93b3e5430954959ea2aad (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.go411
-rw-r--r--internal/processor/card_store.go149
-rw-r--r--internal/processor/image_downloader.go188
-rw-r--r--internal/processor/processor.go945
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