package download import ( "bufio" "crypto/md5" "fmt" "io" "net/http" "os" "path/filepath" "strings" "rhemawavetech.com/rhemawave/internal/logger" ) // DownloadStatus holds information related to downloads and is // the container for information returned via channels within this package. type DownloadStatus struct { Max int64 Cur int64 Msg string Err error } // manifest maps filename → expected MD5 hex string type manifest map[string]string // ─── Server script to generate checksums.md5 ───────────────────────────────── // // Run this on your file server whenever you add or replace audio files: // // cd /path/to/audio && md5sum *.mp3 > checksums.md5 // // That's it. The app will pick up changes automatically on next run. // ───────────────────────────────────────────────────────────────────────────── const manifestFilename = "checksums.md5" // DownloadAudioFiles downloads audio files, skipping any that already exist // locally with a matching MD5. Files with a wrong checksum are deleted and // re-downloaded. Progress is sent to progressChan, which is closed on return. func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlBase string) { defer close(progressChan) send := func(cur, max int64, msg string, err error) { progressChan <- DownloadStatus{Cur: cur, Max: max, Msg: msg, Err: err} } send(0, 0, "Fetching file manifest...", nil) m, err := fetchManifest(urlBase + manifestFilename) if err != nil { send(0, 0, "Failed to fetch manifest", err) return } logger.Info("download: manifest loaded, %d files listed", len(m)) send(0, 0, "Comparing checksums...", nil) type work struct { filename string expectedMD5 string } var queue []work for filename, expectedMD5 := range m { localPath := filepath.Join(destDir, filename) localMD5, err := fileMD5(localPath) if err != nil { logger.Debug("download: %s not found locally, queuing", filename) queue = append(queue, work{filename, expectedMD5}) continue } if localMD5 != expectedMD5 { logger.Debug("download: %s checksum mismatch (got %s, want %s), re-downloading", filename, localMD5, expectedMD5) if err := os.Remove(localPath); err != nil { logger.Warning("download: failed to remove corrupt file %s: %v", localPath, err) } queue = append(queue, work{filename, expectedMD5}) } else { logger.Debug("download: %s OK", filename) } } if len(queue) == 0 { send(0, 0, "All files are up to date.", nil) logger.Info("download: nothing to do") return } total := len(queue) send(0, 0, fmt.Sprintf("0 / %d Files Downloaded", total), nil) logger.Info("download: %d file(s) to download", total) totalDownloaded := int64(0) filesDownloaded := 0 for _, w := range queue { url := urlBase + w.filename destPath := filepath.Join(destDir, w.filename) logger.Debug("download: starting %s", w.filename) downloaded, err := downloadFile(url, destPath, func(n int64) { totalDownloaded += n send(totalDownloaded, 0, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), nil) }) if err != nil { logger.Error("download: failed to download %s: %v", w.filename, err) send(totalDownloaded, 0, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), err) return } gotMD5, err := fileMD5(destPath) if err != nil { logger.Error("download: could not hash %s after download: %v", w.filename, err) os.Remove(destPath) send(totalDownloaded, 0, "Checksum verification failed", err) return } if gotMD5 != w.expectedMD5 { err := fmt.Errorf("checksum mismatch after download: got %s, want %s", gotMD5, w.expectedMD5) logger.Error("download: %s %v", w.filename, err) os.Remove(destPath) send(totalDownloaded, 0, "Checksum verification failed — file removed", err) return } filesDownloaded++ statusMsg := fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total) logger.Info("download: %s (%d bytes)", statusMsg, downloaded) send(totalDownloaded, 0, statusMsg, nil) } send(totalDownloaded, totalDownloaded, "All downloads complete!", nil) logger.Info("download: all files downloaded and verified") } // ─── Helpers ────────────────────────────────────────────────────────────────── // fetchManifest downloads and parses a checksums.md5 file. // Format per line: " " (two spaces, same as md5sum output) func fetchManifest(url string) (manifest, error) { resp, err := http.Get(url) if err != nil { return nil, fmt.Errorf("fetch manifest: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("fetch manifest: server returned %s", resp.Status) } m := make(manifest) scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } // md5sum format: "hash filename" (two spaces) parts := strings.SplitN(line, " ", 2) if len(parts) != 2 { logger.Warning("download: skipping malformed manifest line: %q", line) continue } hash := strings.TrimSpace(parts[0]) name := strings.TrimSpace(parts[1]) m[name] = hash } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("parse manifest: %w", err) } return m, nil } // fileMD5 computes the MD5 checksum of a local file. // Returns an error if the file doesn't exist or can't be read. func fileMD5(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() h := md5.New() if _, err := io.Copy(h, f); err != nil { return "", fmt.Errorf("hash %s: %w", path, err) } return fmt.Sprintf("%x", h.Sum(nil)), nil } // downloadFile streams url to destPath, calling onChunk(n) after each write // with the number of bytes just written. Returns total bytes downloaded. func downloadFile(url, destPath string, onChunk func(int64)) (int64, error) { resp, err := http.Get(url) if err != nil { return 0, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return 0, fmt.Errorf("GET %s: %s", url, resp.Status) } out, err := os.Create(destPath) if err != nil { return 0, fmt.Errorf("create %s: %w", destPath, err) } defer out.Close() var total int64 buf := make([]byte, 32*1024) // 32KB chunks for { n, err := resp.Body.Read(buf) if n > 0 { if _, werr := out.Write(buf[:n]); werr != nil { os.Remove(destPath) return total, fmt.Errorf("write %s: %w", destPath, werr) } total += int64(n) onChunk(int64(n)) } if err == io.EOF { break } if err != nil { os.Remove(destPath) return total, fmt.Errorf("read %s: %w", url, err) } } return total, nil } // ensureDir creates path and all parents if they don't exist. func ensureDir(path string) error { info, err := os.Stat(path) if err == nil { if !info.IsDir() { return fmt.Errorf("path exists but is not a directory: %s", path) } return nil } if !os.IsNotExist(err) { return fmt.Errorf("stat %s: %w", path, err) } if err := os.MkdirAll(path, 0o750); err != nil { return fmt.Errorf("mkdir %s: %w", path, err) } logger.Info("download: created directory %s", path) return nil }