package model
|
|
import (
|
"fmt"
|
"io"
|
"net/http"
|
"os"
|
"os/exec"
|
"path/filepath"
|
"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 {
|
resp, err := http.Get(url)
|
if err != nil {
|
return err
|
}
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
|
}
|
|
totalBytes := resp.ContentLength
|
out, err := os.Create(destPath)
|
if err != nil {
|
return err
|
}
|
defer out.Close()
|
|
buf := make([]byte, 32*1024)
|
var downloaded int64
|
lastPercent := -1.0
|
|
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 {
|
break
|
}
|
return readErr
|
}
|
}
|
|
return nil
|
}
|
|
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
|
})
|
}
|