diff options
Diffstat (limited to 'internal/image/download.go')
| -rw-r--r-- | internal/image/download.go | 223 |
1 files changed, 223 insertions, 0 deletions
diff --git a/internal/image/download.go b/internal/image/download.go new file mode 100644 index 0000000..7a196fa --- /dev/null +++ b/internal/image/download.go @@ -0,0 +1,223 @@ +package image + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// DownloadOptions configures image download behavior. +type DownloadOptions struct { + OutputDir string + OverwriteExisting bool + CreateDir bool + FileNamePattern string + MaxSizeBytes int64 +} + +// DefaultDownloadOptions returns sensible defaults for image downloads. +func DefaultDownloadOptions() *DownloadOptions { + return &DownloadOptions{ + OutputDir: "./images", + OverwriteExisting: false, + CreateDir: true, + FileNamePattern: "{word}_{source}", + MaxSizeBytes: 10 * 1024 * 1024, + } +} + +// Downloader handles image downloads from search results. +type Downloader struct { + provider ImageProvider + options *DownloadOptions +} + +// NewDownloader creates a new image downloader. +func NewDownloader(provider ImageProvider, options *DownloadOptions) *Downloader { + if options == nil { + options = DefaultDownloadOptions() + } + return &Downloader{ + provider: provider, + options: options, + } +} + +// DownloadImage downloads a single image to the specified path. +func (d *Downloader) DownloadImage(ctx context.Context, result *SearchResult, outputPath string) (err error) { + if d == nil || d.provider == nil { + return fmt.Errorf("image provider is required") + } + if result == nil { + return fmt.Errorf("search result is required") + } + + dir := filepath.Dir(outputPath) + if d.options != nil && d.options.CreateDir && dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create output dir %q: %w", dir, err) + } + } + + if d.options != nil && !d.options.OverwriteExisting { + if _, err := os.Stat(outputPath); err == nil { + return fmt.Errorf("output file exists: %s", outputPath) + } + } + + reader, err := d.provider.Download(ctx, result.URL) + if err != nil { + return fmt.Errorf("download %q: %w", result.URL, err) + } + defer func() { + _ = reader.Close() + }() + + file, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create output file %q: %w", outputPath, err) + } + defer func() { + if closeErr := file.Close(); err == nil && closeErr != nil { + err = fmt.Errorf("close output file %q: %w", outputPath, closeErr) + } + }() + + if d.options != nil && d.options.MaxSizeBytes > 0 { + written, copyErr := io.CopyN(file, reader, d.options.MaxSizeBytes) + if copyErr != nil && copyErr != io.EOF { + _ = os.Remove(outputPath) + return fmt.Errorf("write output file %q: %w", outputPath, copyErr) + } + + if written == d.options.MaxSizeBytes { + var probe [1]byte + if n, probeErr := reader.Read(probe[:]); n > 0 || probeErr != io.EOF { + _ = os.Remove(outputPath) + return fmt.Errorf("image exceeds max size %d bytes", d.options.MaxSizeBytes) + } + } + } else { + if _, err = io.Copy(file, reader); err != nil { + _ = os.Remove(outputPath) + return fmt.Errorf("write output file %q: %w", outputPath, err) + } + } + + if err := file.Sync(); err != nil { + return fmt.Errorf("sync output file %q: %w", outputPath, err) + } + + if attribution := d.provider.GetAttribution(result); attribution != "" { + attrPath := strings.TrimSuffix(outputPath, filepath.Ext(outputPath)) + "_attribution.txt" + if err := os.WriteFile(attrPath, []byte(attribution), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to save attribution: %v\n", err) + } + } + + return nil +} + +// DownloadBestMatch downloads the best matching image for a query. +func (d *Downloader) DownloadBestMatch(ctx context.Context, query string) (*SearchResult, string, error) { + opts := DefaultSearchOptions(query) + opts.PerPage = 5 + return d.DownloadBestMatchWithOptions(ctx, opts) +} + +// DownloadBestMatchWithOptions downloads the best matching image for given search options. +func (d *Downloader) DownloadBestMatchWithOptions(ctx context.Context, opts *SearchOptions) (*SearchResult, string, error) { + if d == nil || d.provider == nil { + return nil, "", fmt.Errorf("image provider is required") + } + if opts == nil { + return nil, "", fmt.Errorf("search options are required") + } + + searchOpts := *opts + searchOpts.PerPage = 5 + + results, err := d.provider.Search(ctx, &searchOpts) + if err != nil { + return nil, "", fmt.Errorf("search images: %w", err) + } + if len(results) == 0 { + return nil, "", fmt.Errorf("no images found for %q", opts.Query) + } + + for i, result := range results { + filename := d.generateFileName(opts.Query, &result, i) + outputDir := "./images" + if d.options != nil && d.options.OutputDir != "" { + outputDir = d.options.OutputDir + } + outputPath := filepath.Join(outputDir, filename) + + if err := d.DownloadImage(ctx, &result, outputPath); err == nil { + return &result, outputPath, nil + } else { + fmt.Fprintf(os.Stderr, "Warning: failed to download image %d: %v\n", i+1, err) + } + } + + return nil, "", fmt.Errorf("no downloadable images found for %q", opts.Query) +} + +func (d *Downloader) generateFileName(word string, result *SearchResult, index int) string { + filename := "" + if d != nil && d.options != nil { + filename = d.options.FileNamePattern + } + if filename == "" { + filename = "{word}_{source}" + } + + filename = strings.ReplaceAll(filename, "{word}", sanitizeFileName(word)) + if result != nil { + filename = strings.ReplaceAll(filename, "{source}", result.Source) + filename = strings.ReplaceAll(filename, "{id}", result.ID) + } + filename = strings.ReplaceAll(filename, "{index}", fmt.Sprintf("%d", index)) + + ext := "" + if result != nil { + ext = filepath.Ext(result.URL) + if strings.HasPrefix(result.URL, geminiDataPrefix) { + ext = ".png" + } else if ext == "" || len(ext) > 5 { + ext = ".jpg" + } + } + + if filepath.Ext(filename) == "" { + filename += ext + } + + return filename +} + +func sanitizeFileName(name string) string { + replacer := strings.NewReplacer( + "/", "_", + "\\", "_", + ":", "_", + "*", "_", + "?", "_", + "\"", "_", + "<", "_", + ">", "_", + "|", "_", + " ", "_", + ".", "_", + ) + + sanitized := replacer.Replace(name) + if len(sanitized) > 50 { + sanitized = sanitized[:50] + } + + return sanitized +} |
