From 05bddac137607102f12c1c464db34a1e10707af6 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 6 Apr 2026 10:16:14 +0300 Subject: 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 --- internal/audio/provider.go | 1 - internal/audio/provider_test.go | 1 - internal/batch/processor.go | 1 - internal/batch/processor_test.go | 1 - internal/cli/flags.go | 36 +- internal/gui/app.go | 8 +- internal/image/search.go | 4 +- internal/phonetic/fetcher.go | 12 +- internal/processor/audio_coordinator.go | 411 ++++++++++++++ internal/processor/card_store.go | 149 +++++ internal/processor/image_downloader.go | 188 +++++++ internal/processor/processor.go | 945 +++++++------------------------- internal/story/generator.go | 2 +- internal/story/runner.go | 2 - 14 files changed, 981 insertions(+), 780 deletions(-) create mode 100644 internal/processor/audio_coordinator.go create mode 100644 internal/processor/card_store.go create mode 100644 internal/processor/image_downloader.go (limited to 'internal') diff --git a/internal/audio/provider.go b/internal/audio/provider.go index 60be848..e4e5888 100644 --- a/internal/audio/provider.go +++ b/internal/audio/provider.go @@ -154,4 +154,3 @@ func NewProvider(config *Config) (Provider, error) { return nil, fmt.Errorf("unknown audio provider: %s", config.Provider) } } - diff --git a/internal/audio/provider_test.go b/internal/audio/provider_test.go index 92bb94e..8cf5bfa 100644 --- a/internal/audio/provider_test.go +++ b/internal/audio/provider_test.go @@ -129,4 +129,3 @@ func TestNewProvider(t *testing.T) { }) } } - diff --git a/internal/batch/processor.go b/internal/batch/processor.go index 2b002a9..c785a91 100644 --- a/internal/batch/processor.go +++ b/internal/batch/processor.go @@ -103,4 +103,3 @@ func parseBatchLine(line string) *WordEntry { CardType: internal.CardTypeEnBg, } } - diff --git a/internal/batch/processor_test.go b/internal/batch/processor_test.go index 4ec1739..85171d8 100644 --- a/internal/batch/processor_test.go +++ b/internal/batch/processor_test.go @@ -165,4 +165,3 @@ func TestReadBatchFile_FileNotFound(t *testing.T) { t.Error("Expected error for non-existent file") } } - diff --git a/internal/cli/flags.go b/internal/cli/flags.go index 9c83138..df493a4 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -16,25 +16,25 @@ type Flags struct { // AudioFormatSpecified records whether the audio format was explicitly set on the CLI. AudioFormatSpecified bool // AudioProvider selects the text-to-speech backend ("gemini" or "openai"). - AudioProvider string - ImageAPI string - ImageAPISpecified bool - BatchFile string - StoryFile string // --story : generate vocabulary story + comic image - StoryStyle string // --story-style: override the random art style (empty = random) - StoryTheme string // --story-theme: override the random genre pick (empty = random) + AudioProvider string + ImageAPI string + ImageAPISpecified bool + BatchFile string + StoryFile string // --story : generate vocabulary story + comic image + StoryStyle string // --story-style: override the random art style (empty = random) + StoryTheme string // --story-theme: override the random genre pick (empty = random) StoryNoUltraRealistic bool // --no-ultra-realistic: disable photorealistic rendering requirement - StorySlug string // --story-slug: force a specific output slug/directory (empty = auto from title) - NarratorVoice string // --narrator-voice: Gemini voice for cinematic narration (empty = random) - SkipAudio bool - SkipImages bool - GenerateAnki bool - AnkiCSV bool - DeckName string - ListModels bool - AllVoices bool - NoAutoPlay bool - Archive bool + StorySlug string // --story-slug: force a specific output slug/directory (empty = auto from title) + NarratorVoice string // --narrator-voice: Gemini voice for cinematic narration (empty = random) + SkipAudio bool + SkipImages bool + GenerateAnki bool + AnkiCSV bool + DeckName string + ListModels bool + AllVoices bool + NoAutoPlay bool + Archive bool // OpenAI flags OpenAIModel string diff --git a/internal/gui/app.go b/internal/gui/app.go index 13b3136..b66d66e 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -107,9 +107,9 @@ type Application struct { activeOpMu sync.Mutex // Mutex for activeOperations map // Injectable factory functions — replaced in tests to avoid real API calls. - newOpenAIImageClient func(*image.OpenAIConfig) promptAwareImageClient + newOpenAIImageClient func(*image.OpenAIConfig) promptAwareImageClient newNanoBananaImageClient func(*image.NanoBananaConfig) promptAwareImageClient - newAudioProvider func(*audio.Config) (audio.Provider, error) + newAudioProvider func(*audio.Config) (audio.Provider, error) } // Config holds GUI application configuration @@ -225,9 +225,9 @@ func New(config *Config) *Application { autoPlayEnabled: config.AutoPlay, // Use config setting // Production defaults for factory functions; replaced in tests. - newOpenAIImageClient: func(c *image.OpenAIConfig) promptAwareImageClient { return image.NewOpenAIClient(c) }, + newOpenAIImageClient: func(c *image.OpenAIConfig) promptAwareImageClient { return image.NewOpenAIClient(c) }, newNanoBananaImageClient: func(c *image.NanoBananaConfig) promptAwareImageClient { return image.NewNanoBananaClient(c) }, - newAudioProvider: audio.NewProvider, + newAudioProvider: audio.NewProvider, } // Initialize the word processing queue diff --git a/internal/image/search.go b/internal/image/search.go index 80d5e33..7e405da 100644 --- a/internal/image/search.go +++ b/internal/image/search.go @@ -27,8 +27,8 @@ type SearchOptions struct { Page int // Page number (1-based) ImageType string // Type: "photo", "illustration", "vector", "all" Orientation string // Orientation: "horizontal", "vertical", "all" - CustomPrompt string // Custom prompt for AI image generation - AspectRatio string // Override aspect ratio (e.g. "9:16"); empty = provider default + CustomPrompt string // Custom prompt for AI image generation + AspectRatio string // Override aspect ratio (e.g. "9:16"); empty = provider default // ReferenceImages holds raw PNG bytes of previously generated images. // When non-empty, the NanoBanana client sends them as multimodal content // alongside the text prompt so the model can match character appearance diff --git a/internal/phonetic/fetcher.go b/internal/phonetic/fetcher.go index 7856ac7..aa821b5 100644 --- a/internal/phonetic/fetcher.go +++ b/internal/phonetic/fetcher.go @@ -22,15 +22,15 @@ const ( // ProviderOpenAI routes phonetic requests to OpenAI. ProviderOpenAI Provider = "openai" - defaultGeminiModel = "gemini-2.5-flash" - defaultOpenAIModel = openai.GPT4o - phoneticTimeout = 30 * time.Second - phoneticRetryCount = 3 - phoneticTemperature = 0.3 + defaultGeminiModel = "gemini-2.5-flash" + defaultOpenAIModel = openai.GPT4o + phoneticTimeout = 30 * time.Second + phoneticRetryCount = 3 + phoneticTemperature = 0.3 // 200 tokens gives ample room for IPA of multi-syllable Bulgarian phrases. // 50 was too tight: Gemini 2.5 Flash can emit several thinking tokens before // the IPA bracket pair, causing the output to be silently truncated mid-symbol. - phoneticMaxTokens = 200 + phoneticMaxTokens = 200 phoneticSystemPrompt = "You are a Bulgarian language expert. Provide only the IPA (International Phonetic Alphabet) transcription for Bulgarian words. Return ONLY the IPA transcription in square brackets, nothing else. No explanations, no word labels, just the IPA." ) 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: +// - .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 -// that do not need a deadline may pass context.Background(). +// 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 operation. +// ProcessBatch passes a per-word deadline; callers without a deadline may pass +// context.Background(). func (p *Processor) ProcessWordWithTranslationAndType(ctx context.Context, word, providedTranslation string, cardType internal.CardType) error { - var translationText string - - // For bg-bg cards, translation is the back side (Bulgarian definition) - // For en-bg cards, translation is the English word - if providedTranslation != "" { - translationText = providedTranslation - if cardType.IsBgBg() { - fmt.Printf(" Using provided definition: %s\n", translationText) - } else { - fmt.Printf(" Using provided translation: %s\n", translationText) - } - } else if !cardType.IsBgBg() { - // Only translate to English for en-bg cards - fmt.Printf(" Translating to English...\n") - var err error - translationText, err = p.translator.TranslateWord(word) - if err != nil { - fmt.Printf(" Warning: Translation failed: %v\n", err) - translationText = "" - } else { - fmt.Printf(" Translation: %s\n", translationText) - } - } + translationText := p.resolveTranslation(ctx, word, providedTranslation, cardType) - // Find or create word directory wordDir := p.findOrCreateWordDirectory(word) - // Save card type if err := internal.SaveCardType(wordDir, cardType); err != nil { fmt.Printf(" Warning: Failed to save card type: %v\n", err) } - // Store translation for Anki export - if translationText != "" { - p.translationCache.Add(word, translationText) - - // Check if translation file already exists - translationFile := filepath.Join(wordDir, "translation.txt") - if _, err := os.Stat(translationFile); os.IsNotExist(err) { - if err := translation.SaveTranslation(wordDir, word, translationText); err != nil { - fmt.Printf(" Warning: Failed to save translation: %v\n", err) - } - } else { - fmt.Printf(" Translation file already exists\n") - } + if err := p.saveTranslationIfNeeded(word, translationText, wordDir); err != nil { + fmt.Printf(" Warning: Failed to save translation: %v\n", err) } - // Fetch phonetic information fmt.Printf(" Fetching phonetic information...\n") if err := p.phoneticFetcher.FetchAndSave(word, wordDir); err != nil { fmt.Printf(" Warning: Failed to fetch phonetic info: %v\n", err) @@ -307,22 +296,13 @@ func (p *Processor) ProcessWordWithTranslationAndType(ctx context.Context, word, fmt.Printf(" Saved phonetic information\n") } - // Generate audio if !p.flags.SkipAudio { fmt.Printf(" Generating audio...\n") - if cardType.IsBgBg() { - // Generate audio for both sides - if err := p.generateAudioBgBg(ctx, word, translationText); err != nil { - return fmt.Errorf("audio generation failed: %w", err) - } - } else { - if err := p.generateAudio(ctx, word); err != nil { - return fmt.Errorf("audio generation failed: %w", err) - } + if err := p.generateAudioForCard(ctx, word, translationText, cardType); err != nil { + return fmt.Errorf("audio generation failed: %w", err) } } - // Download images - pass the translation for better image generation if !p.flags.SkipImages { fmt.Printf(" Downloading images...\n") if err := p.downloadImagesWithTranslation(ctx, word, translationText); err != nil { @@ -333,436 +313,182 @@ func (p *Processor) ProcessWordWithTranslationAndType(ctx context.Context, word, return nil } -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 "" -} - -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 +// resolveTranslation determines the effective translation text for the word. +// For bg-bg cards it uses the provided definition; for en-bg cards it fetches +// an English translation when none was provided. +func (p *Processor) resolveTranslation(_ context.Context, word, providedTranslation string, cardType internal.CardType) string { + if providedTranslation != "" { + if cardType.IsBgBg() { + fmt.Printf(" Using provided definition: %s\n", providedTranslation) + } else { + fmt.Printf(" Using provided translation: %s\n", providedTranslation) } + return providedTranslation } - 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 cardType.IsBgBg() { + // bg-bg cards do not need an English translation. + return "" } - if p.audioProviderName() == "gemini" { - return audio.DefaultProviderConfig().OutputFormat + fmt.Printf(" Translating to English...\n") + translationText, err := p.translator.TranslateWord(word) + if err != nil { + fmt.Printf(" Warning: Translation failed: %v\n", err) + return "" } - - return "mp3" + fmt.Printf(" Translation: %s\n", translationText) + return translationText } -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) +// saveTranslationIfNeeded stores the translation in the in-memory cache and +// writes translation.txt to wordDir if the file does not already exist. +func (p *Processor) saveTranslationIfNeeded(word, translationText, wordDir string) error { + if translationText == "" { + return nil } - return "" -} -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 "" -} + p.translationCache.Add(word, translationText) -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) + translationFile := filepath.Join(wordDir, "translation.txt") + if _, err := os.Stat(translationFile); os.IsNotExist(err) { + return translation.SaveTranslation(wordDir, word, translationText) } - return "" -} -// audioVoicesForProvider returns the voice list for the configured provider -// without needing a Provider instance (uses the package-level VoicesFor). -func (p *Processor) audioVoicesForProvider() []string { - return audio.VoicesFor(p.audioProviderName()) + fmt.Printf(" Translation file already exists\n") + return nil } -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))] +// generateAudioForCard dispatches audio generation to the appropriate helper +// based on card type. bg-bg cards need audio for both front and back sides. +func (p *Processor) generateAudioForCard(ctx context.Context, word, translationText string, cardType internal.CardType) error { + if cardType.IsBgBg() { + return p.generateAudioBgBg(ctx, word, translationText) } + return p.generateAudio(ctx, word) } -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) - } +// GenerateAnkiFile generates the Anki import file and returns the output path. +// When --anki is specified the file is placed in the user's home directory; +// otherwise it goes into the configured output directory. +func (p *Processor) GenerateAnkiFile() (string, error) { + outputDir, err := p.resolveAnkiOutputDir() + if err != nil { + return "", err } -} - -// generateAudio generates audio files for a word using the configured provider. -// 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() - // Get the provider-specific voice list. - var voices []string - if p.flags.AllVoices { - voices = p.audioVoicesForProvider() - } else { - voice := p.audioVoiceForProvider() - p.logSelectedAudioVoice(provider, voice) - 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 - } - voices = []string{voice} - } + audioFormat := p.effectiveAudioFormat() + gen := anki.NewGenerator(&anki.GeneratorOptions{ + OutputPath: filepath.Join(outputDir, "anki_import.csv"), + MediaFolder: p.flags.OutputDir, + IncludeHeaders: true, + AudioFormat: audioFormat, + }) - // Generate audio for each voice - for i, voice := range voices { - if p.flags.AllVoices { - 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) - } + if err := p.populateAnkiGenerator(gen, audioFormat); err != nil { + return "", err } - return nil + return p.writeAnkiOutput(gen, outputDir) } -// generateAudioBgBg generates audio files for both sides of a bg-bg 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) +// resolveAnkiOutputDir returns the directory where the Anki file should be +// written. When --anki is set it resolves to the user's home directory. +func (p *Processor) resolveAnkiOutputDir() (string, error) { + if p.flags.GenerateAnki { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) } - - return nil - } - - 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 - } - - if err := generatePair(voice); err != nil { - return err + return homeDir, nil } - - return nil -} - -// generateAudioWithVoice generates audio for a word with a specific voice. -func (p *Processor) generateAudioWithVoice(ctx context.Context, word, voice string) error { - return p.generateAudioWithVoiceAndFilename(ctx, word, voice, "audio") + return p.flags.OutputDir, nil } -// generateAudioWithVoiceAndFilename generates audio for a word with a specific voice and filename. -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 generates audio for a word and saves it to a specific directory. -// ctx is passed directly to provider.GenerateAudio so the caller's cancellation and deadline apply -// to the TTS API call; use context.Background() when no deadline is needed. -func (p *Processor) generateAudioWithVoiceAndFilenameInDir(ctx context.Context, word, voice, filenameBase, wordDir string) error { - 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 { - // Default was used, generate random speed - speed = 0.90 + rand.Float64()*0.10 - } - - // Create audio provider configuration - 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: - providerConfig.OutputFormat = audioFormat - providerConfig.OpenAIModel = p.flags.OpenAIModel - providerConfig.OpenAIVoice = voice - providerConfig.OpenAISpeed = speed - providerConfig.OpenAIInstruction = p.flags.OpenAIInstruction - - // Use config file values if not overridden by flags - 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 +// populateAnkiGenerator fills the generator with cards. When the translation +// cache is populated it is used as the authoritative source; otherwise the +// generator falls back to scanning the output directory for existing cards. +func (p *Processor) populateAnkiGenerator(gen *anki.Generator, audioFormat string) error { + translations := p.translationCache.GetAll() + if len(translations) == 0 { + fmt.Println(" No translations found in cache, generating cards from directory...") + if err := gen.GenerateFromDirectory(p.flags.OutputDir); err != nil { + return fmt.Errorf("failed to generate cards from directory: %w", err) } + return nil } - // Create the audio provider - provider, err := p.newAudioProvider(providerConfig) - if err != nil { - return err - } - - // Build filename using the provided base - outputFormat := providerConfig.OutputFormat - var outputFile string - if p.flags.AllVoices && filenameBase == "audio" { - outputFile = filepath.Join(wordDir, fmt.Sprintf("%s_%s.%s", filenameBase, voice, outputFormat)) - } else { - outputFile = filepath.Join(wordDir, fmt.Sprintf("%s.%s", filenameBase, outputFormat)) - } - - // Generate the audio - err = provider.GenerateAudio(ctx, word, outputFile) - if err != nil { - return err - } - - // Save audio attribution - if err := p.saveAudioAttribution(word, outputFile, providerConfig); err != nil { - fmt.Printf(" Warning: Failed to save audio attribution: %v\n", err) + fmt.Printf(" Generating cards from %d translations in cache...\n", len(translations)) + for bulgarian, english := range translations { + card := p.buildAnkiCard(bulgarian, english, audioFormat) + gen.AddCard(card) } - return nil } -// downloadImagesWithTranslation downloads images for a word. -// ctx is passed to the image downloader so the caller's cancellation and deadline apply -// to the image search and download API calls. -func (p *Processor) downloadImagesWithTranslation(ctx context.Context, word, translationText string) error { - searcher, err := p.newImageSearcher() - if err != nil { - return err +// buildAnkiCard constructs an anki.Card for a word, resolving all associated +// media files (audio, image, phonetic) from the word's card directory. +func (p *Processor) buildAnkiCard(bulgarian, english, audioFormat string) anki.Card { + card := anki.Card{ + Bulgarian: bulgarian, + Translation: english, } - // Find existing card directory or create new one - wordDir := p.findOrCreateWordDirectory(word) - - // Create downloader - downloadOpts := &image.DownloadOptions{ - OutputDir: wordDir, - OverwriteExisting: true, - CreateDir: true, - FileNamePattern: "image", - MaxSizeBytes: 5 * 1024 * 1024, // 5MB + wordDir := p.findCardDirectory(bulgarian) + if wordDir == "" { + return card } - downloader := image.NewDownloader(searcher, downloadOpts) - - // Create search options with translation if provided - searchOpts := image.DefaultSearchOptions(word) - if translationText != "" { - searchOpts.Translation = translationText + cardType := internal.LoadCardType(wordDir) + if cardType.IsBgBg() { + card.AudioFile = anki.ResolveAudioFile(wordDir, "audio_front", audioFormat) + card.AudioFileBack = anki.ResolveAudioFile(wordDir, "audio_back", audioFormat) + } else { + card.AudioFile = anki.ResolveAudioFile(wordDir, "audio", audioFormat) } - type promptSetter interface { - SetPromptCallback(func(prompt string)) - } - if promptAware, ok := searcher.(promptSetter); ok { - 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) - } - }) + // Image file (prefer .jpg; the downloader may use other extensions). + imageFile := filepath.Join(wordDir, "image.jpg") + if _, err := os.Stat(imageFile); err == nil { + card.ImageFile = imageFile } - // Download single image using the caller-provided context so deadlines propagate. - _, path, err := downloader.DownloadBestMatchWithOptions(ctx, searchOpts) - if err != nil { - return err + // Phonetic notes (newlines replaced with HTML line breaks for Anki). + phoneticFile := filepath.Join(wordDir, "phonetic.txt") + if data, err := os.ReadFile(phoneticFile); err == nil { + notes := strings.TrimSpace(string(data)) + card.Notes = strings.ReplaceAll(notes, "\n", "
") } - fmt.Printf(" Downloaded: %s\n", path) - p.saveImagePrompt(wordDir, searcher) - - return nil + return card } -// GenerateAnkiFile generates the Anki import file and returns the output path -func (p *Processor) GenerateAnkiFile() (string, error) { - // When --anki is used from CLI, save to home directory - var outputDir string - if p.flags.GenerateAnki { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) - } - outputDir = homeDir - } else { - outputDir = p.flags.OutputDir - } - - // Create Anki generator - audioFormat := p.effectiveAudioFormat() - gen := anki.NewGenerator(&anki.GeneratorOptions{ - OutputPath: filepath.Join(outputDir, "anki_import.csv"), - MediaFolder: p.flags.OutputDir, - IncludeHeaders: true, - AudioFormat: audioFormat, - }) - - // Use the translation cache as the source of truth for cards - translations := p.translationCache.GetAll() - if len(translations) == 0 { - fmt.Println(" No translations found in cache, generating cards from directory...") - // Fallback to old method if cach