diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-02 21:51:48 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-02 21:51:48 +0300 |
| commit | 29f634d3413924af3a9dd6f89983c29b5320fe66 (patch) | |
| tree | f3dd4f556d770e7e3fca0f822f51162865c35c83 /internal/processor | |
| parent | 993b2efe63e221cee550756770894c9c58474d25 (diff) | |
task 00c/00f: thread context through processor audio/image calls; per-word batch timeout
- Thread ctx context.Context through generateAudio, generateAudioBgBg,
generateAudioWithVoice, generateAudioWithVoiceAndFilename,
generateAudioWithVoiceAndFilenameInDir, and downloadImagesWithTranslation.
Also add ctx to ProcessWordWithTranslationAndType so callers can control
the deadline for the full word-processing pipeline.
- In ProcessBatch, create a context.WithTimeout(5 * time.Minute) per word
so a single hung TTS or image API call cannot block the whole batch run.
Previously both functions created context.Background() internally, making
cancellation impossible.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/processor')
| -rw-r--r-- | internal/processor/processor.go | 72 | ||||
| -rw-r--r-- | internal/processor/processor_test.go | 28 |
2 files changed, 55 insertions, 45 deletions
diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 5b426bb..54c99c3 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -113,7 +113,12 @@ func (p *Processor) ProcessBatch() error { continue } - if err := p.ProcessWordWithTranslationAndType(entry.Bulgarian, entry.Translation, entry.CardType); err != nil { + // 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. + 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++ } else { @@ -152,11 +157,14 @@ func (p *Processor) ProcessSingleWord(word string) error { // ProcessWordWithTranslation processes a word with optional provided translation (en-bg mode) func (p *Processor) ProcessWordWithTranslation(word, providedTranslation string) error { - return p.ProcessWordWithTranslationAndType(word, providedTranslation, internal.CardTypeEnBg) + return p.ProcessWordWithTranslationAndType(context.Background(), word, providedTranslation, internal.CardTypeEnBg) } -// ProcessWordWithTranslationAndType processes a word with optional provided translation and card type -func (p *Processor) ProcessWordWithTranslationAndType(word, providedTranslation string, cardType internal.CardType) error { +// 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(). +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) @@ -217,11 +225,11 @@ func (p *Processor) ProcessWordWithTranslationAndType(word, providedTranslation fmt.Printf(" Generating audio...\n") if cardType.IsBgBg() { // Generate audio for both sides - if err := p.generateAudioBgBg(word, translationText); err != nil { + if err := p.generateAudioBgBg(ctx, word, translationText); err != nil { return fmt.Errorf("audio generation failed: %w", err) } } else { - if err := p.generateAudio(word); err != nil { + if err := p.generateAudio(ctx, word); err != nil { return fmt.Errorf("audio generation failed: %w", err) } } @@ -230,7 +238,7 @@ func (p *Processor) ProcessWordWithTranslationAndType(word, providedTranslation // Download images - pass the translation for better image generation if !p.flags.SkipImages { fmt.Printf(" Downloading images...\n") - if err := p.downloadImagesWithTranslation(word, translationText); err != nil { + if err := p.downloadImagesWithTranslation(ctx, word, translationText); err != nil { return fmt.Errorf("image download failed: %w", err) } } @@ -353,8 +361,9 @@ func (p *Processor) logSelectedAudioVoice(provider, voice string) { } } -// generateAudio generates audio files for a word -func (p *Processor) generateAudio(word string) error { +// 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. @@ -369,7 +378,7 @@ func (p *Processor) generateAudio(word string) error { if candidate != voice { fmt.Printf(" Retrying Gemini audio with voice: %s\n", candidate) } - return p.generateAudioWithVoice(word, candidate) + return p.generateAudioWithVoice(ctx, word, candidate) }, func(candidate string) { fmt.Printf(" Warning: Gemini returned no audio for voice %s\n", candidate) }) @@ -383,7 +392,7 @@ func (p *Processor) generateAudio(word string) error { if p.flags.AllVoices { fmt.Printf(" Generating audio %d/%d (voice: %s)...\n", i+1, len(voices), voice) } - if err := p.generateAudioWithVoice(word, voice); err != nil { + if err := p.generateAudioWithVoice(ctx, word, voice); err != nil { return fmt.Errorf("failed to generate audio with voice %s: %w", voice, err) } } @@ -391,25 +400,26 @@ func (p *Processor) generateAudio(word string) error { return nil } -// generateAudioBgBg generates audio files for both sides of a bg-bg card -func (p *Processor) generateAudioBgBg(front, back string) error { +// 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 + // 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(front, candidate, "audio_front", wordDir); err != nil { + 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(back, candidate, "audio_back", wordDir); err != nil { + if err := p.generateAudioWithVoiceAndFilenameInDir(ctx, back, candidate, "audio_back", wordDir); err != nil { return fmt.Errorf("failed to generate back audio: %w", err) } @@ -435,19 +445,21 @@ func (p *Processor) generateAudioBgBg(front, back string) error { return nil } -// generateAudioWithVoice generates audio for a word with a specific voice -func (p *Processor) generateAudioWithVoice(word, voice string) error { - return p.generateAudioWithVoiceAndFilename(word, voice, "audio") +// 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") } -// generateAudioWithVoiceAndFilename generates audio for a word with a specific voice and filename -func (p *Processor) generateAudioWithVoiceAndFilename(word, voice, filenameBase string) error { +// 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(word, voice, filenameBase, wordDir) + return p.generateAudioWithVoiceAndFilenameInDir(ctx, word, voice, filenameBase, wordDir) } -// generateAudioWithVoiceAndFilenameInDir generates audio for a word and saves it to a specific directory -func (p *Processor) generateAudioWithVoiceAndFilenameInDir(word, voice, filenameBase, wordDir string) error { +// 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() @@ -500,9 +512,6 @@ func (p *Processor) generateAudioWithVoiceAndFilenameInDir(word, voice, filename return err } - // Generate audio file - ctx := context.Background() - // Build filename using the provided base outputFormat := providerConfig.OutputFormat var outputFile string @@ -526,8 +535,10 @@ func (p *Processor) generateAudioWithVoiceAndFilenameInDir(word, voice, filename return nil } -// downloadImagesWithTranslation downloads images for a word -func (p *Processor) downloadImagesWithTranslation(word, translationText string) error { +// 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 @@ -568,8 +579,7 @@ func (p *Processor) downloadImagesWithTranslation(word, translationText string) }) } - // Download single image - ctx := context.Background() + // Download single image using the caller-provided context so deadlines propagate. _, path, err := downloader.DownloadBestMatchWithOptions(ctx, searchOpts) if err != nil { return err diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go index dcb8e06..db03266 100644 --- a/internal/processor/processor_test.go +++ b/internal/processor/processor_test.go @@ -390,7 +390,7 @@ func TestGenerateAudioUsesSharedOpenAIVoices(t *testing.T) { p := NewProcessor(flags) - if err := p.generateAudio("ябълка"); err != nil { + if err := p.generateAudio(context.Background(), "ябълка"); err != nil { t.Fatalf("generateAudio() unexpected error: %v", err) } @@ -439,7 +439,7 @@ func TestGenerateAudioBgBgUsesSharedOpenAIVoices(t *testing.T) { flags.AudioProvider = "openai" p := NewProcessor(flags) - if err := p.generateAudioBgBg("ябълка", "круша"); err != nil { + if err := p.generateAudioBgBg(context.Background(), "ябълка", "круша"); err != nil { t.Fatalf("generateAudioBgBg() unexpected error: %v", err) } @@ -520,7 +520,7 @@ func TestGenerateAudioProviderFactoryError(t *testing.T) { flags.AudioProvider = "openai" p := NewProcessor(flags) - err := p.generateAudio("ябълка") + err := p.generateAudio(context.Background(), "ябълка") if err == nil { t.Fatal("generateAudio() expected error from provider factory") } @@ -558,7 +558,7 @@ func TestGenerateAudioUsesConfiguredGeminiVoiceAndModel(t *testing.T) { flags.AudioFormat = "mp3" p := NewProcessor(flags) - if err := p.generateAudio("ябълка!?"); err != nil { + if err := p.generateAudio(context.Background(), "ябълка!?"); err != nil { t.Fatalf("generateAudio() unexpected error: %v", err) } @@ -657,7 +657,7 @@ func TestGenerateAudioUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) { flags.AudioProvider = "gemini" p := NewProcessor(flags) - if err := p.generateAudio("ябълка!?"); err != nil { + if err := p.generateAudio(context.Background(), "ябълка!?"); err != nil { t.Fatalf("generateAudio() unexpected error: %v", err) } @@ -748,7 +748,7 @@ func TestGenerateGeminiAudioWithFallbacksRetriesAlternateVoice(t *testing.T) { p := NewProcessor(flags) p.randomIntn = func(int) int { return 0 } output := captureStdout(t, func() { - if err := p.generateAudio("ябълка"); err != nil { + if err := p.generateAudio(context.Background(), "ябълка"); err != nil { t.Fatalf("generateAudio() unexpected error: %v", err) } }) @@ -812,7 +812,7 @@ func TestGenerateAudioReturnsExhaustedGeminiFallbackError(t *testing.T) { flags.AudioProvider = "gemini" p := NewProcessor(flags) - err := p.generateAudio("ябълка") + err := p.generateAudio(context.Background(), "ябълка") if !errors.Is(err, audio.ErrGeminiNoAudioData) { t.Fatalf("generateAudio() error = %v, want wrapped ErrGeminiNoAudioData", err) } @@ -854,7 +854,7 @@ func TestGenerateAudioBgBgUsesGeminiModelDefaultWhenVoiceNotSet(t *testing.T) { flags.AudioProvider = "gemini" p := NewProcessor(flags) - if err := p.generateAudioBgBg("ябълка!?", "круша."); err != nil { + if err := p.generateAudioBgBg(context.Background(), "ябълка!?", "круша."); err != nil { t.Fatalf("generateAudioBgBg() unexpected error: %v", err) } @@ -916,7 +916,7 @@ func TestGenerateAudioUsesConfiguredAudioFormatWhenOpenAIConfigIsSetOnly(t *test p := NewProcessor(flags) wordDir := p.findOrCreateWordDirectory("ябълка!?") - if err := p.generateAudioWithVoiceAndFilenameInDir("ябълка!?", "alloy", "audio", wordDir); err != nil { + if err := p.generateAudioWithVoiceAndFilenameInDir(context.Background(), "ябълка!?", "alloy", "audio", wordDir); err != nil { t.Fatalf("generateAudioWithVoiceAndFilenameInDir() unexpected error: %v", err) } @@ -992,7 +992,7 @@ func TestGenerateAudioUsesConfiguredOpenAIVoiceFromConfig(t *testing.T) { flags.AudioFormat = "mp3" p := NewProcessor(flags) - if err := p.generateAudio("ябълка"); err != nil { + if err := p.generateAudio(context.Background(), "ябълка"); err != nil { t.Fatalf("generateAudio() unexpected error: %v", err) } @@ -1069,7 +1069,7 @@ func TestGenerateAudioOmitsOpenAIInstructionsForUnsupportedModel(t *testing.T) { flags.OpenAIModel = "tts-1" p := NewProcessor(flags) - if err := p.generateAudio("ябълка!?"); err != nil { + if err := p.generateAudio(context.Background(), "ябълка!?"); err != nil { t.Fatalf("generateAudio() unexpected error: %v", err) } @@ -1232,7 +1232,7 @@ func TestDownloadImagesWithTranslationUsesNanoBananaConfigAndSavesPrompt(t *test flags.ImageAPISpecified = true p := NewProcessor(flags) - if err := p.downloadImagesWithTranslation("ябълка", "apple"); err != nil { + if err := p.downloadImagesWithTranslation(context.Background(), "ябълка", "apple"); err != nil { t.Fatalf("downloadImagesWithTranslation() unexpected error: %v", err) } @@ -1287,7 +1287,7 @@ func TestDownloadImagesWithTranslationPersistsPromptWhenDownloadFails(t *testing flags.ImageAPISpecified = true p := NewProcessor(flags) - err := p.downloadImagesWithTranslation("ябълка", "apple") + err := p.downloadImagesWithTranslation(context.Background(), "ябълка", "apple") if err == nil { t.Fatal("downloadImagesWithTranslation() expected error from failed download") } @@ -1337,7 +1337,7 @@ func TestDownloadImagesWithTranslationUsesConfiguredNanoBananaWhenImageAPINotSpe flags.ImageAPISpecified = false p := NewProcessor(flags) - if err := p.downloadImagesWithTranslation("ябълка", "apple"); err != nil { + if err := p.downloadImagesWithTranslation(context.Background(), "ябълка", "apple"); err != nil { t.Fatalf("downloadImagesWithTranslation() unexpected error: %v", err) } |
