| | |
| | | package model |
| | | |
| | | import ( |
| | | "errors" |
| | | "fmt" |
| | | "io" |
| | | "net/http" |
| | | "os" |
| | | "os/exec" |
| | | "path/filepath" |
| | | "strconv" |
| | | "strings" |
| | | "time" |
| | | |
| | | "voicesnap/internal/logger" |
| | |
| | | } |
| | | |
| | | func downloadFile(url, destPath string, progress ProgressCallback) error { |
| | | resp, err := http.Get(url) |
| | | const maxAttempts = 6 |
| | | var lastErr error |
| | | var lastPercent = -1.0 |
| | | |
| | | for attempt := 1; attempt <= maxAttempts; attempt++ { |
| | | downloaded := existingFileSize(destPath) |
| | | err := downloadFileAttempt(url, destPath, downloaded, &lastPercent, progress) |
| | | if err == nil { |
| | | return nil |
| | | } |
| | | if isNonRetryableDownloadError(err) { |
| | | return err |
| | | } |
| | | lastErr = err |
| | | logger.Info("Download attempt %d/%d failed: %v", attempt, maxAttempts, err) |
| | | if attempt < maxAttempts { |
| | | time.Sleep(time.Duration(attempt) * time.Second) |
| | | } |
| | | } |
| | | |
| | | return fmt.Errorf("download failed after %d attempts: %w", maxAttempts, lastErr) |
| | | } |
| | | |
| | | func downloadFileAttempt(url, destPath string, resumeFrom int64, lastPercent *float64, progress ProgressCallback) error { |
| | | req, err := http.NewRequest(http.MethodGet, url, nil) |
| | | if err != nil { |
| | | return nonRetryableDownloadError{err: err} |
| | | } |
| | | if resumeFrom > 0 { |
| | | req.Header.Set("Range", fmt.Sprintf("bytes=%d-", resumeFrom)) |
| | | } |
| | | |
| | | resp, err := http.DefaultClient.Do(req) |
| | | if err != nil { |
| | | return err |
| | | } |
| | | defer resp.Body.Close() |
| | | |
| | | if resp.StatusCode != http.StatusOK { |
| | | return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) |
| | | appendMode := resumeFrom > 0 && resp.StatusCode == http.StatusPartialContent |
| | | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { |
| | | err := fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) |
| | | if resp.StatusCode < http.StatusInternalServerError { |
| | | return nonRetryableDownloadError{err: err} |
| | | } |
| | | return err |
| | | } |
| | | if resumeFrom > 0 && resp.StatusCode == http.StatusOK { |
| | | logger.Info("Download server ignored Range request; restarting download") |
| | | resumeFrom = 0 |
| | | appendMode = false |
| | | } |
| | | |
| | | totalBytes := resp.ContentLength |
| | | out, err := os.Create(destPath) |
| | | if appendMode { |
| | | if total := parseContentRangeTotal(resp.Header.Get("Content-Range")); total > 0 { |
| | | totalBytes = total |
| | | } else if resp.ContentLength > 0 { |
| | | totalBytes = resumeFrom + resp.ContentLength |
| | | } |
| | | } |
| | | |
| | | flag := os.O_CREATE | os.O_WRONLY |
| | | if appendMode { |
| | | flag |= os.O_APPEND |
| | | } else { |
| | | flag |= os.O_TRUNC |
| | | } |
| | | out, err := os.OpenFile(destPath, flag, 0644) |
| | | if err != nil { |
| | | return err |
| | | } |
| | | defer out.Close() |
| | | |
| | | downloaded := resumeFrom |
| | | if !appendMode { |
| | | downloaded = 0 |
| | | } |
| | | buf := make([]byte, 32*1024) |
| | | var downloaded int64 |
| | | lastPercent := -1.0 |
| | | |
| | | for { |
| | | n, readErr := resp.Body.Read(buf) |
| | | if n > 0 { |
| | |
| | | |
| | | if totalBytes > 0 && progress != nil { |
| | | pct := float64(downloaded) / float64(totalBytes) * 100 |
| | | if pct-lastPercent >= 0.5 { |
| | | lastPercent = pct |
| | | if pct-*lastPercent >= 0.5 { |
| | | *lastPercent = pct |
| | | progress(pct, downloaded, totalBytes) |
| | | } |
| | | } |
| | | } |
| | | if readErr != nil { |
| | | if readErr == io.EOF { |
| | | break |
| | | if totalBytes > 0 && downloaded < totalBytes { |
| | | return fmt.Errorf("short download: got %d of %d bytes", downloaded, totalBytes) |
| | | } |
| | | return nil |
| | | } |
| | | return readErr |
| | | } |
| | | } |
| | | } |
| | | |
| | | return nil |
| | | func existingFileSize(path string) int64 { |
| | | info, err := os.Stat(path) |
| | | if err != nil || info.IsDir() { |
| | | return 0 |
| | | } |
| | | return info.Size() |
| | | } |
| | | |
| | | func parseContentRangeTotal(value string) int64 { |
| | | if value == "" { |
| | | return 0 |
| | | } |
| | | slash := strings.LastIndex(value, "/") |
| | | if slash < 0 || slash == len(value)-1 { |
| | | return 0 |
| | | } |
| | | total, err := strconv.ParseInt(value[slash+1:], 10, 64) |
| | | if err != nil { |
| | | return 0 |
| | | } |
| | | return total |
| | | } |
| | | |
| | | type nonRetryableDownloadError struct { |
| | | err error |
| | | } |
| | | |
| | | func (e nonRetryableDownloadError) Error() string { |
| | | return e.err.Error() |
| | | } |
| | | |
| | | func (e nonRetryableDownloadError) Unwrap() error { |
| | | return e.err |
| | | } |
| | | |
| | | func isNonRetryableDownloadError(err error) bool { |
| | | var target nonRetryableDownloadError |
| | | return errors.As(err, &target) |
| | | } |
| | | |
| | | func extractArchive(archivePath, destDir string) error { |