diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-10 23:11:30 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-10 23:11:30 +0300 |
| commit | 65430e9876a32988839deb7a70dc1e42da21cf64 (patch) | |
| tree | e0de040cb53d5b90587908f2d55e86e49664fc35 /internal/processor/failed_asset_retry.go | |
| parent | 4b4b3665230faafbd994f09894cb07c8f9d4b359 (diff) | |
Release 0.28.2v0.28.2
Diffstat (limited to 'internal/processor/failed_asset_retry.go')
| -rw-r--r-- | internal/processor/failed_asset_retry.go | 290 |
1 files changed, 290 insertions, 0 deletions
diff --git a/internal/processor/failed_asset_retry.go b/internal/processor/failed_asset_retry.go new file mode 100644 index 0000000..88d1abe --- /dev/null +++ b/internal/processor/failed_asset_retry.go @@ -0,0 +1,290 @@ +package processor + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "codeberg.org/snonux/totalrecall/internal" + "codeberg.org/snonux/totalrecall/internal/anki" + "codeberg.org/snonux/totalrecall/internal/audio" + "codeberg.org/snonux/totalrecall/internal/store" +) + +type failedAssetKind string + +const ( + failedAssetAudio failedAssetKind = "audio" + failedAssetImage failedAssetKind = "image" + failedAssetBgBgAudioPair failedAssetKind = "front and back audio" + failedAssetBgBgFrontAudio failedAssetKind = "front audio" + failedAssetBgBgBackAudio failedAssetKind = "back audio" +) + +type failedAssetPlan struct { + Card store.CardDirectory + CardType internal.CardType + Translation string + ImagePrompt string + Assets []failedAssetKind +} + +// RetryFailedAssets scans the existing card output directory for incomplete or +// failed asset generations and retries them in deterministic order. The retry +// loop stops immediately on the first fresh error so users can rerun the same +// command after an upstream rate limit clears. +func (p *Processor) RetryFailedAssets() error { + if err := os.MkdirAll(p.Flags.OutputDir, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + plans, err := p.scanFailedAssetPlans() + if err != nil { + return err + } + if len(plans) == 0 { + fmt.Printf("No failed assets found in: %s\n", p.Flags.OutputDir) + return nil + } + + totalAssets := 0 + for _, plan := range plans { + totalAssets += len(plan.Assets) + } + + fmt.Printf("Found %d failed asset(s) across %d card(s) in %s\n", totalAssets, len(plans), p.Flags.OutputDir) + + regenerated := 0 + for _, plan := range plans { + fmt.Printf("\nRetrying card: %s\n", plan.Card.Word) + for _, asset := range plan.Assets { + fmt.Printf(" Regenerating %s...\n", asset) + + assetCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + err := p.regenerateFailedAsset(assetCtx, plan, asset) + cancel() + if err != nil { + return fmt.Errorf("stopped after %d successful regeneration(s); %s for %q failed: %w", regenerated, asset, plan.Card.Word, err) + } + + regenerated++ + } + } + + fmt.Printf("\nRegenerated %d failed asset(s).\n", regenerated) + return nil +} + +func (p *Processor) scanFailedAssetPlans() ([]failedAssetPlan, error) { + cards := p.cardStore.ListCardDirectories(nil) + plans := make([]failedAssetPlan, 0, len(cards)) + + for _, card := range cards { + plan := p.buildFailedAssetPlan(card) + if len(plan.Assets) == 0 { + continue + } + plans = append(plans, plan) + } + + return plans, nil +} + +func (p *Processor) buildFailedAssetPlan(card store.CardDirectory) failedAssetPlan { + plan := failedAssetPlan{ + Card: card, + CardType: internal.LoadCardType(card.Path), + Translation: readStoredTranslation(card.Path), + ImagePrompt: readStoredImagePrompt(card.Path), + } + + if !p.Flags.SkipAudio { + if plan.CardType.IsBgBg() { + frontReady := audioAssetReady(card.Path, "audio_front", p.EffectiveAudioFormat()) + backReady := audioAssetReady(card.Path, "audio_back", p.EffectiveAudioFormat()) + + switch { + case !frontReady && !backReady: + plan.Assets = append(plan.Assets, failedAssetBgBgAudioPair) + case !frontReady: + plan.Assets = append(plan.Assets, failedAssetBgBgFrontAudio) + case !backReady: + plan.Assets = append(plan.Assets, failedAssetBgBgBackAudio) + } + } else if !audioAssetReady(card.Path, "audio", p.EffectiveAudioFormat()) { + plan.Assets = append(plan.Assets, failedAssetAudio) + } + } + + if !p.Flags.SkipImages && !imageAssetReady(card.Path) { + plan.Assets = append(plan.Assets, failedAssetImage) + } + + return plan +} + +func (p *Processor) regenerateFailedAsset(ctx context.Context, plan failedAssetPlan, asset failedAssetKind) error { + switch asset { + case failedAssetAudio: + return p.generateAudio(ctx, plan.Card.Word) + case failedAssetImage: + return p.downloadImagesWithPrompt(ctx, plan.Card.Word, plan.Translation, plan.ImagePrompt) + case failedAssetBgBgAudioPair: + if strings.TrimSpace(plan.Translation) == "" { + return fmt.Errorf("missing back-side text in translation.txt") + } + return p.generateAudioBgBg(ctx, plan.Card.Word, plan.Translation) + case failedAssetBgBgFrontAudio: + return p.generateCardAudioSideInDir(ctx, plan.Card.Word, plan.Card.Path, "audio_front", "front audio") + case failedAssetBgBgBackAudio: + if strings.TrimSpace(plan.Translation) == "" { + return fmt.Errorf("missing back-side text in translation.txt") + } + return p.generateCardAudioSideInDir(ctx, plan.Translation, plan.Card.Path, "audio_back", "back audio") + default: + return fmt.Errorf("unknown failed asset kind %q", asset) + } +} + +func (p *Processor) generateCardAudioSideInDir(ctx context.Context, text, wordDir, filenameBase, label string) error { + provider := p.AudioProviderName() + voice := p.audioVoiceForProvider() + p.logSelectedAudioVoice(provider, voice) + + run := func(candidate string) error { + if candidate != voice { + fmt.Printf(" Retrying Gemini audio with voice: %s\n", candidate) + } + fmt.Printf(" Generating %s for '%s'...\n", label, text) + return p.generateAudioWithVoiceAndFilenameInDir(ctx, text, candidate, filenameBase, wordDir) + } + + if provider == "gemini" && p.GeminiVoice() == "" { + _, err := audio.RunWithVoiceFallbacks(voice, run, func(candidate string) { + fmt.Printf(" Warning: Gemini returned no audio for voice %s\n", candidate) + }) + return err + } + + return run(voice) +} + +func audioAssetReady(wordDir, baseName, preferredFormat string) bool { + paths := anki.ResolveAudioPaths(wordDir, baseName, preferredFormat) + if len(paths) == 0 { + return false + } + + for _, path := range paths { + if !fileExistsAndNonEmpty(path) || !fileExistsAndNonEmpty(audio.AttributionPath(path)) { + return false + } + } + + return fileExistsAndNonEmpty(filepath.Join(wordDir, "audio_metadata.txt")) +} + +func imageAssetReady(wordDir string) bool { + if firstUsableImagePath(wordDir) == "" { + return false + } + if !fileExistsAndNonEmpty(filepath.Join(wordDir, "image_attribution.txt")) { + return false + } + return readStoredImagePrompt(wordDir) != "" +} + +func firstUsableImagePath(wordDir string) string { + 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)) + for _, match := range matches { + if fileExistsAndNonEmpty(match) { + return match + } + } + continue + } + + path := filepath.Join(wordDir, pattern) + if fileExistsAndNonEmpty(path) { + return path + } + } + + return "" +} + +func readStoredTranslation(wordDir string) string { + data, err := os.ReadFile(filepath.Join(wordDir, "translation.txt")) + if err != nil { + return "" + } + + parts := strings.SplitN(string(data), "=", 2) + if len(parts) != 2 { + return strings.TrimSpace(string(data)) + } + + return strings.TrimSpace(parts[1]) +} + +func readStoredImagePrompt(wordDir string) string { + data, err := os.ReadFile(filepath.Join(wordDir, "image_prompt.txt")) + if err != nil { + return "" + } + + prompt := strings.TrimSpace(string(data)) + if prompt == "" || looksLikeFailedPrompt(prompt) { + return "" + } + + return prompt +} + +func looksLikeFailedPrompt(prompt string) bool { + lower := strings.ToLower(strings.TrimSpace(prompt)) + if lower == "" { + return true + } + + markers := []string{ + "rate limit", + "too many requests", + "quota exceeded", + "resource exhausted", + "failed to generate", + "generation failed", + "temporarily unavailable", + "http 429", + } + + for _, marker := range markers { + if strings.Contains(lower, marker) { + return true + } + } + + return false +} + +func fileExistsAndNonEmpty(path string) bool { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return false + } + return info.Size() > 0 +} |
