package model import ( "errors" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "strconv" "strings" "time" "voicesnap/internal/logger" ) // ProgressCallback is called with download progress (percent, downloaded bytes, total bytes). type ProgressCallback func(percent float64, downloaded, total int64) // Download downloads and extracts the ASR model. // It tries the primary URL first, then falls back to the fallback URL. func Download(primaryURL, fallbackURL, modelsDir string, progress ProgressCallback) error { profile := DefaultModelProfile() urls := []string{primaryURL, fallbackURL} return DownloadProfile(profile, urls, modelsDir, progress) } func DownloadProfile(profile ModelProfile, urls []string, modelsDir string, progress ProgressCallback) error { if err := os.MkdirAll(modelsDir, 0755); err != nil { return fmt.Errorf("failed to create models dir: %w", err) } cleanupStaleDownloadDirs(profile, modelsDir) var lastErr error for i, url := range urls { if url == "" { continue } if err := downloadAndInstallFromURL(profile, url, modelsDir, i+1, progress); err != nil { lastErr = err logger.Info("Model install from URL %d failed: %v", i+1, err) continue } if err := UpdateInstalledModelState(modelsDir, profile, profile.InstallDirName, "new"); err != nil { logger.Error("Failed to update model install state: %v", err) } logger.Info("Model download and extraction complete") return nil } if lastErr == nil { lastErr = fmt.Errorf("no download URL configured") } return fmt.Errorf("download failed: %w", lastErr) } func cleanupStaleDownloadDirs(profile ModelProfile, modelsDir string) { downloadRoot := filepath.Join(modelsDir, ".downloads", profile.ID) if err := os.RemoveAll(downloadRoot); err != nil { logger.Info("Failed to remove stale download dir %s: %v", downloadRoot, err) } } func downloadAndInstallFromURL(profile ModelProfile, url, modelsDir string, attempt int, progress ProgressCallback) error { runID := fmt.Sprintf("%d-%d", time.Now().UnixNano(), attempt) downloadDir := filepath.Join(modelsDir, ".downloads", profile.ID, runID) extractDir := filepath.Join(downloadDir, "extract") stagingDir := filepath.Join(modelsDir, ".staging", profile.ID+"-"+runID) archivePath := filepath.Join(downloadDir, "model_package") if err := os.MkdirAll(extractDir, 0755); err != nil { return fmt.Errorf("failed to create download dir: %w", err) } defer os.RemoveAll(downloadDir) defer os.RemoveAll(stagingDir) logger.Info("Downloading model from URL %d: %s", attempt, url) if err := downloadFile(url, archivePath, progress); err != nil { return err } logger.Info("Extracting model archive...") if err := extractArchive(archivePath, extractDir); err != nil { return fmt.Errorf("extraction failed: %w", err) } sourceDir, err := findInstallSource(profile, extractDir) if err != nil { return err } if err := moveOrCopyDir(sourceDir, stagingDir); err != nil { return fmt.Errorf("failed to stage model: %w", err) } validation := ValidateModelDir(profile, stagingDir) if !validation.Valid { return fmt.Errorf("downloaded model is incomplete: missing=%v problems=%v", validation.Missing, validation.Problems) } finalDir := filepath.Join(modelsDir, profile.InstallDirName) return installStagedModel(profile, stagingDir, finalDir, runID) } func downloadFile(url, destPath string, progress ProgressCallback) error { 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() 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 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) for { n, readErr := resp.Body.Read(buf) if n > 0 { if _, writeErr := out.Write(buf[:n]); writeErr != nil { return writeErr } downloaded += int64(n) if totalBytes > 0 && progress != nil { pct := float64(downloaded) / float64(totalBytes) * 100 if pct-*lastPercent >= 0.5 { *lastPercent = pct progress(pct, downloaded, totalBytes) } } } if readErr != nil { if readErr == io.EOF { if totalBytes > 0 && downloaded < totalBytes { return fmt.Errorf("short download: got %d of %d bytes", downloaded, totalBytes) } return nil } return readErr } } } 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 { cmd := exec.Command("tar", "-xf", archivePath, "-C", destDir) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("tar extraction failed: %v, output: %s", err, string(output)) } return nil } func findInstallSource(profile ModelProfile, extractDir string) (string, error) { if validation := ValidateModelDir(profile, extractDir); validation.Valid { return extractDir, nil } var found string err := filepath.WalkDir(extractDir, func(path string, entry os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if found != "" || !entry.IsDir() { return nil } if path == extractDir { return nil } if validation := ValidateModelDir(profile, path); validation.Valid { found = path return filepath.SkipDir } return nil }) if err != nil { return "", err } if found == "" { return "", fmt.Errorf("no complete %s model found in archive", profile.ID) } return found, nil } func installStagedModel(profile ModelProfile, stagingDir, finalDir, runID string) error { var backupDir string if _, err := os.Stat(finalDir); err == nil { backupDir = finalDir + ".backup-" + runID if err := os.Rename(finalDir, backupDir); err != nil { return fmt.Errorf("failed to move existing model dir: %w", err) } } if err := os.Rename(stagingDir, finalDir); err != nil { if copyErr := copyDir(stagingDir, finalDir); copyErr != nil { restoreBackup(finalDir, backupDir) return fmt.Errorf("failed to install staged model: rename=%v copy=%w", err, copyErr) } } if validation := ValidateModelDir(profile, finalDir); !validation.Valid { restoreBackup(finalDir, backupDir) return fmt.Errorf("installed model is incomplete: missing=%v problems=%v", validation.Missing, validation.Problems) } if backupDir != "" { os.RemoveAll(backupDir) } return nil } func restoreBackup(finalDir, backupDir string) { if backupDir == "" { return } os.RemoveAll(finalDir) os.Rename(backupDir, finalDir) } func moveOrCopyDir(src, dst string) error { os.RemoveAll(dst) if err := os.Rename(src, dst); err == nil { return nil } return copyDir(src, dst) } func copyDir(src, dst string) error { return filepath.WalkDir(src, func(path string, entry os.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } rel, err := filepath.Rel(src, path) if err != nil { return err } target := filepath.Join(dst, rel) info, err := entry.Info() if err != nil { return err } if entry.IsDir() { return os.MkdirAll(target, info.Mode()) } in, err := os.Open(path) if err != nil { return err } defer in.Close() out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) if err != nil { return err } if _, err := io.Copy(out, in); err != nil { out.Close() return err } if err := out.Close(); err != nil { return err } return nil }) }