Replace AudioFile with AudioTrack, fix download+orphan+MD5 sync, always play original audio fallback

AudioTrack table replaces AudioFile (Filename, Name, AssignedFrequency,
MD5, Selected, Order) — fixes relative-path bug in scrollerPage.go that
caused "file not found" for single-track selection.

Download goroutine now:
  - Accepts *gorm.DB and updates AudioTrack.MD5 after each download.
  - Treats checksums.md5 as source of truth: removes local files and DB
    records not in the manifest (orphans).
  - Strips "./" prefix from manifest filenames (GNU md5sum format).

AudioOptions.go seeds 14 AudioTrack records matching server checksums.md5.
List background quad width matches the widest item (+ padding), centered.

scrollerPage.go switchAudioForFrequency falls back to original/selected
audio when a frequency has no matching track or file.  playOriginalAudio
is a no-op on repeat calls to avoid 5-second audio restart loop.

Fixes progress display: passes Max=0 during downloads so Home page shows
"x / X Files Downloaded" text instead of bogus "100.0%" percentage.

Downstream audio switching (DynamicAudio) verifies an AudioTrack exists
for the frequency before assigning it to a stream — frequencies without
a track (e.g. 528 Hz) gracefully use original audio instead.
This commit is contained in:
NeilClack
2026-07-29 16:19:15 -05:00
parent 3480e7594a
commit 91711eef93
8 changed files with 402 additions and 152 deletions
+71 -29
View File
@@ -10,6 +10,8 @@ import (
"path/filepath"
"strings"
"gorm.io/gorm"
"rhemawavetech.com/rhemawave/internal/db"
"rhemawavetech.com/rhemawave/internal/logger"
)
@@ -25,36 +27,36 @@ type DownloadStatus struct {
// 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) {
// DownloadAudioFiles downloads audio files using the remote checksums.md5 as
// the source of truth. It:
// - Downloads any file in the manifest that is missing locally.
// - Re-downloads files whose local MD5 differs from the manifest.
// - Removes local files not listed in the manifest (orphans).
// - Removes DB AudioTrack records not listed in the manifest.
// - Updates AudioTrack.MD5 in the database after every successful download.
//
// Progress is sent to progressChan, which is closed on return.
func DownloadAudioFiles(progressChan chan<- DownloadStatus, dbHandle *gorm.DB, 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}
var totalDownloaded int64
var filesDownloaded int
send := func(cur int64, msg string, err error) {
progressChan <- DownloadStatus{Cur: cur, Max: 0, Msg: msg, Err: err}
}
send(0, 0, "Fetching file manifest...", nil)
send(0, "Fetching file manifest...", nil)
m, err := fetchManifest(urlBase + manifestFilename)
if err != nil {
send(0, 0, "Failed to fetch manifest", err)
send(0, "Failed to fetch manifest", err)
return
}
logger.Info("download: manifest loaded, %d files listed", len(m))
send(0, 0, "Comparing checksums...", nil)
send(0, "Comparing checksums...", nil)
type work struct {
filename string
@@ -82,19 +84,47 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlB
}
}
// ── Remove local files not in manifest (orphans) ─────────────────────
entries, err := os.ReadDir(destDir)
if err == nil {
for _, entry := range entries {
if entry.IsDir() {
continue
}
if _, ok := m[entry.Name()]; !ok {
orphanPath := filepath.Join(destDir, entry.Name())
logger.Info("download: removing orphan file %s", orphanPath)
if err := os.Remove(orphanPath); err != nil {
logger.Warning("download: failed to remove orphan %s: %v", orphanPath, err)
}
}
}
} else {
logger.Warning("download: could not read destDir for orphan cleanup: %v", err)
}
// ── Remove DB records not in manifest ────────────────────────────────
if dbHandle != nil {
var allTracks []db.AudioTrack
dbHandle.Find(&allTracks)
for _, track := range allTracks {
if _, ok := m[track.Filename]; !ok {
logger.Info("download: removing orphan DB record %s (filename=%s)", track.Name, track.Filename)
dbHandle.Delete(&track)
}
}
}
if len(queue) == 0 {
send(0, 0, "All files are up to date.", nil)
send(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)
send(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)
@@ -103,12 +133,12 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlB
downloaded, err := downloadFile(url, destPath, func(n int64) {
totalDownloaded += n
send(totalDownloaded, 0, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), nil)
send(totalDownloaded, 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)
send(totalDownloaded, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), err)
return
}
@@ -116,24 +146,35 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlB
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)
send(totalDownloaded, "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)
send(totalDownloaded, "Checksum verification failed — file removed", err)
return
}
// Update MD5 in database
if dbHandle != nil {
var track db.AudioTrack
result := dbHandle.Where("filename = ?", w.filename).First(&track)
if result.Error == nil {
track.MD5 = gotMD5
dbHandle.Save(&track)
logger.Debug("download: updated MD5 for %s", w.filename)
}
}
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, statusMsg, nil)
}
send(totalDownloaded, totalDownloaded, "All downloads complete!", nil)
send(totalDownloaded, "All downloads complete!", nil)
logger.Info("download: all files downloaded and verified")
}
@@ -167,6 +208,7 @@ func fetchManifest(url string) (manifest, error) {
}
hash := strings.TrimSpace(parts[0])
name := strings.TrimSpace(parts[1])
name = strings.TrimPrefix(name, "./")
m[name] = hash
}