From f00cb9efb6a045ea7c9d139bb4f42636c4b5e9c8 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Sat, 27 Jun 2026 13:59:22 -0500 Subject: [PATCH] Fix download progress and prevent re-downloading existing audio. --- TestList.org | 31 ++ VMTestList.org | 44 +++ VMTestList.org~ | 5 + internal/application/application.go | 37 +-- internal/download/Download.go | 427 ++++++++++++++++------------ internal/page/home.go | 81 +++--- 6 files changed, 377 insertions(+), 248 deletions(-) create mode 100644 TestList.org create mode 100644 VMTestList.org create mode 100644 VMTestList.org~ diff --git a/TestList.org b/TestList.org new file mode 100644 index 0000000..1df8a26 --- /dev/null +++ b/TestList.org @@ -0,0 +1,31 @@ +- [X] Networks listed +- [X] Cursor moves between networks +- [X] Enter opens a password prompt for a protected network +- [X] You can type and see what you are typing +- [X] Email address page appears +- [X] Can type in email +- [X] Can submit email +- [X] License Key page appears +- [X] Can type in license key +- [X] Can submit license key +- [X] Version string is correct +- [X] Status: Downloading Assets +- [X] F3 navigates to settings +- [X] Cursor moves +- [X] Can view application logs +- [X] No logs are leaking proprietary information +- [X] WiFi setup shows connected to a network +- [X] License Key shows current license key +- [X] I can backspace the license key +- [X] I cannot submit an incomplete key +- [X] I can type in the box +- [X] I can submit the new key +- [X] License Key shows updated key +- [X] Email address shows current email +- [X] I can backspace characters +- [X] I cannot submit an incomplete email +- [X] I can type in the box +- [X] I can submit the new email +- [X] Email address shows new email +- [X] Audio page loads +- [ ] I can toggle the audio on and off diff --git a/VMTestList.org b/VMTestList.org new file mode 100644 index 0000000..224817a --- /dev/null +++ b/VMTestList.org @@ -0,0 +1,44 @@ +- [X] Version string is correct +- [X] Status shows READY or Downloading Assets +- [X] Email address is present and correct +- [X] License key is present and correct +- [X] Settings menu opens +- [X] Logs open +- [X] No proprietary data is exposed in logs +- [X] Logs are colored correctly +- [X] WiFi Setup opens +- [X] WiFi page shows "Something went wrong" because there are no WiFi nics available +- [X] License key opens +- [X] License key displays current license key +- [X] Backspace deletes characters +- [X] I can type in a new license key +- [X] I can submit a license key +- [X] License Key shows new license key +- [X] I cannot submit an incomplete license key +- [X] Email address opens +- [X] Email addrss displays current email address +- [X] Backspace deletes characters +- [X] I can type a new email address +- [X] I can submit an email address +- [X] Email address shows new email address +- [X] I cannot submit an invalid email address +- [X] Audio opens +- [X] I can toggle audio +- [X] At least one audio device is listed +- [X] Volume is at 100% +- [X] I can change the volume +- [X] Returning to the Audio page shows toggle and volume updated values +- [X] Proclamation Browser opens +- [X] Proclamation Browser is populated +- [X] Space bar opens and closes categories +- [X] Enter opens and closes categories +- [X] Space toggles a proclamation +- [X] Enter toggles a proclamation +- [X] Typing characters starts a search +- [X] I can search for Confidence and ADHD +- [X] Enter during search toggles selection +- [X] Space during search toggles selection +- [X] Returning to the Proclamation browser shows my saved proclamations +- [X] Scroller opens and starts playing +- [X] Audio is playing +- [X] Colors appear correct diff --git a/VMTestList.org~ b/VMTestList.org~ new file mode 100644 index 0000000..036024b --- /dev/null +++ b/VMTestList.org~ @@ -0,0 +1,5 @@ +- [ ] Version string is correct +- [ ] Status shows READY or Downloading Assets +- [ ] + + diff --git a/internal/application/application.go b/internal/application/application.go index fbee563..eff967b 100644 --- a/internal/application/application.go +++ b/internal/application/application.go @@ -164,29 +164,32 @@ func Run() { sync.SyncProclamations(DB) sync.SyncCategories(DB) sync.SyncFrequencies(DB) - audioFileNames := []string{ - "OriginalAudio.wav", - "174Hz.wav", - "396Hz.wav", - "417Hz.wav", - "432Hz.wav", - "639Hz.wav", - "741Hz.wav", - "852Hz.wav", - "963Hz.wav", - "1111Hz.wav", - "14Hz_Binaural.wav", - "40Hz_Binaural.wav", - "AlphaWaves.wav", - "PinealGland.wav", - } + + // audioFileNames := []string{ + // "OriginalAudio.wav", + // "174Hz.wav", + // "396Hz.wav", + // "417Hz.wav", + // "432Hz.wav", + // "639Hz.wav", + // "741Hz.wav", + // "852Hz.wav", + // "963Hz.wav", + // "1111Hz.wav", + // "14Hz_Binaural.wav", + // "40Hz_Binaural.wav", + // "AlphaWaves.wav", + // "PinealGland.wav", + // } config, err := db.GetConfig(app.DB) if err != nil { panic(err) } - go download.DownloadAudioFiles(app.PageRouter.Context.MessageChannel, audioFileNames, filepath.Join(config.AssetsDir, "audio")) + audioDir := filepath.Join(config.AssetsDir, "audio") + + go download.DownloadAudioFiles(app.PageRouter.Context.MessageChannel, audioDir, "https://files.radiantwavetech.com/audio/") app.runEventLoop() } diff --git a/internal/download/Download.go b/internal/download/Download.go index e43df1d..94437d4 100644 --- a/internal/download/Download.go +++ b/internal/download/Download.go @@ -1,214 +1,271 @@ 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. +// the container for information returned via channels within this package. type DownloadStatus struct { - Max int64 // Total bytes to download - Cur int64 // Total bytes downloaded - Msg string // Status message - Err error // Error, if any + Max int64 + Cur int64 + Msg string + Err error } -// ensureDir checks if a dire +// 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 +} + +// remoteFileSize does a HEAD request and returns Content-Length. +func remoteFileSize(url string) (int64, error) { + resp, err := http.Head(url) + if err != nil { + return 0, err + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("HEAD %s: %s", url, resp.Status) + } + return resp.ContentLength, 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) } - fmt.Printf("Directory already exists: %s\n", path) return nil } - if !os.IsNotExist(err) { - return fmt.Errorf("error checking path: %w", err) + return fmt.Errorf("stat %s: %w", path, err) } - - // 0760 = rwxrw---- (owner: rwx, group: rw-, others: ---) - if err := os.MkdirAll(path, 0760); err != nil { - return fmt.Errorf("failed to create directory: %w", err) + if err := os.MkdirAll(path, 0o750); err != nil { + return fmt.Errorf("mkdir %s: %w", path, err) } - - logger.Info("Directory created: %s\n", path) + logger.Info("download: created directory %s", path) return nil } - -// DownloadAudioFiles downloads multiple audio files and sends progress updates -// to the provided channel. The channel is closed when all downloads are complete. -func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, destDir string) { - defer close(progressChan) - - logger.Info("Starting downloads") - - if err := ensureDir(destDir); err != nil { - logger.Error("failed to ensureDir %s %v", destDir, err) - progressChan <- DownloadStatus{ - Msg: fmt.Sprintf("Failed to ensure directory exists: %v", err), - Err: err, - } - } - - urlBase := "https://files.radiantwavetech.com/audio/" - - // First, calculate the total size of files that need to be downloaded - totalSize := int64(0) - filesToDownload := []string{} - totalDownloaded := int64(0) - - for _, filename := range filenames { - logger.Debug("Downloading %s", filename) - destPath := filepath.Join(destDir, filename) - // Check if the file already exists - url := urlBase + filename - resp, err := http.Head(url) - if err != nil { - logger.Warning("Failed to get size for %s: %v", filename, err) - progressChan <- DownloadStatus{ - Msg: fmt.Sprintf("Failed to get size for %s: %v", filename, err), - Err: err, - } - return - } - if resp.StatusCode != http.StatusOK { - logger.Error("Failed to get size for %s: status %s", filename, resp.Status) - progressChan <- DownloadStatus{ - Msg: fmt.Sprintf("Failed to get size for %s: status %s", filename, resp.Status), - Err: fmt.Errorf("bad status: %s", resp.Status), - } - return - } - fileStat, err := os.Stat(destPath) - // If the file is incomplete, or does not exist - if os.IsNotExist(err) || fileStat.Size() != resp.ContentLength { - // log a warning if the file is not complete - if !os.IsNotExist(err) { - logger.Warning("%s corrupt. Adding to download queue.", filename) - } else { - logger.Debug("%s not found. Adding to download queue.", filename) - } - - // Append it to the list to download - filesToDownload = append(filesToDownload, filename) - } else if err != nil { - logger.Error("unknown error: %v", err) - } else { - logger.Warning("File %s exists, skipping...", filename) - totalDownloaded += fileStat.Size() - } - // Calculate the total of ALL files to give the illusion of - // "picking up where you left off" to the user - totalSize += resp.ContentLength - resp.Body.Close() - } - - // Send initial status - progressChan <- DownloadStatus{ - Max: totalSize, - Cur: 0, - Msg: "Starting download...", - } - - // Download each file that doesn't exist - for _, filename := range filesToDownload { - url := urlBase + filename - destPath := filepath.Join(destDir, filename) - - // Ensure the destination directory exists - - // Create the destination file - out, err := os.Create(destPath) - if err != nil { - logger.Error("Failed to create file %s: %v", destPath, err) - progressChan <- DownloadStatus{ - Msg: fmt.Sprintf("Failed to create file %s: %v", destPath, err), - Err: err, - } - return - } - - // Get the file - resp, err := http.Get(url) - if err != nil { - out.Close() - os.Remove(destPath) - logger.Error("Failed to download %s: %v", filename, err) - progressChan <- DownloadStatus{ - Msg: fmt.Sprintf("Failed to download %s: %v", filename, err), - Err: err, - } - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - out.Close() - os.Remove(destPath) - logger.Error("Failed to download %s: status %s", filename, resp.Status) - progressChan <- DownloadStatus{ - Msg: fmt.Sprintf("Failed to download %s: status %s", filename, resp.Status), - Err: fmt.Errorf("bad status: %s", resp.Status), - } - return - } - - // Use a buffered reader for efficiency - buf := make([]byte, 8192) - for { - n, err := resp.Body.Read(buf) - if err != nil && err != io.EOF { - out.Close() - os.Remove(destPath) - logger.Error("Failed to read %s: %v", filename, err) - progressChan <- DownloadStatus{ - Msg: fmt.Sprintf("Failed to read %s: %v", filename, err), - Err: err, - } - return - } - if n == 0 { - break - } - - // Write to file - if _, err := out.Write(buf[:n]); err != nil { - out.Close() - os.Remove(destPath) - logger.Error("Failed to write %s: %v", filename, err) - progressChan <- DownloadStatus{ - Msg: fmt.Sprintf("Failed to write %s: %v", filename, err), - Err: err, - } - return - } - - // Update progress - totalDownloaded += int64(n) - progressChan <- DownloadStatus{ - Max: totalSize, - Cur: totalDownloaded, - Msg: fmt.Sprintf("%s", filename), - } - } - - out.Close() - } - - // Send final status - progressChan <- DownloadStatus{ - Max: totalSize, - Cur: totalDownloaded, - Msg: "All audio downloads completed!", - } - logger.Info("All audio downloads complete!") -} diff --git a/internal/page/home.go b/internal/page/home.go index 2c6edb3..7ad8be0 100644 --- a/internal/page/home.go +++ b/internal/page/home.go @@ -171,72 +171,61 @@ func (p *Home) Update(dt float32) error { select { case progressUpdate, ok = <-p.Context.MessageChannel: gotUpdate = true - if !ok { - // Channel was closed while draining. - newMsg, err := NewStringItem("Status: READY", p.font, colors.Green) - if err != nil { - return err - } - messageScale := float32(28) / float32(p.baseFontSize) - newMsg.W = int32(float32(newMsg.W) * messageScale) - newMsg.H = int32(float32(newMsg.H) * messageScale) - p.message = newMsg + p.setMessage("Status: READY", colors.Green) p.isDownloadComplete = true - return nil } - default: goto drained } } drained: - if !gotUpdate { return nil } if progressUpdate.Err != nil { - // Handle error - newMsg, err := NewStringItem( - fmt.Sprintf("Status: %s", progressUpdate.Err.Error()), - p.font, - colors.Red, - ) - if err != nil { - return err - } - - messageScale := float32(28) / float32(p.baseFontSize) - newMsg.W = int32(float32(newMsg.W) * messageScale) - newMsg.H = int32(float32(newMsg.H) * messageScale) - p.message = newMsg - - } else { - // Update progress message using ONLY the latest update. - percentComplete := float64(progressUpdate.Cur) / float64(progressUpdate.Max) * 100 - - msg := fmt.Sprintf( - "Status: Downloading assets %.1f%% Complete", - percentComplete, - ) - - newMsg, err := NewStringItem(msg, p.font, colors.White) - if err != nil { - return err - } - - messageScale := float32(28) / float32(p.baseFontSize) - newMsg.W = int32(float32(newMsg.W) * messageScale) - newMsg.H = int32(float32(newMsg.H) * messageScale) - p.message = newMsg + p.setMessage(fmt.Sprintf("Error: %s", progressUpdate.Err.Error()), colors.Red) + return nil } + // Build the display string based on what the download package tells us. + var msg string + if progressUpdate.Max <= 0 { + // No total known yet — show the descriptive message from the package. + // This covers: "Fetching file manifest", "Checking local files", + // "Comparing checksums", "Building download list", etc. + msg = fmt.Sprintf("Status: %s", progressUpdate.Msg) + } else { + // We have a known total — show percentage. + pct := float64(progressUpdate.Cur) / float64(progressUpdate.Max) * 100 + msg = fmt.Sprintf("Status: Downloading Assets — %.1f%%", pct) + } + + p.setMessage(msg, colors.White) return nil } +// setMessage deletes the old message texture and builds a new one. +func (p *Home) setMessage(text string, color sdl.Color) { + if p.message.ID != 0 { + gl.DeleteTextures(1, &p.message.ID) + p.message.ID = 0 + } + + newMsg, err := NewStringItem(text, p.font, color) + if err != nil { + return + } + + messageScale := float32(28) / float32(p.baseFontSize) + newMsg.W = int32(float32(newMsg.W) * messageScale) + newMsg.H = int32(float32(newMsg.H) * messageScale) + p.message = newMsg +} + func (p *Home) Render() error { baseFontSize := p.baseFontSize sm := shaderManager.Get()