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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package download_test
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
"rhemawavetech.com/rhemawave/internal/download"
|
||||
)
|
||||
|
||||
func md5Hex(data []byte) string {
|
||||
return fmt.Sprintf("%x", md5.Sum(data))
|
||||
}
|
||||
|
||||
// TestDownloadFileHashChanged verifies that when the server manifest reports a
|
||||
// new MD5 for an existing local file, the old file is removed, the new version
|
||||
// is downloaded, and the database record is updated with the new hash.
|
||||
func TestDownloadFileHashChanged(t *testing.T) {
|
||||
audioDir := t.TempDir()
|
||||
dbDir := t.TempDir()
|
||||
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(dbDir, "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := gdb.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
|
||||
if err := gdb.AutoMigrate(&db.AudioTrack{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldContent := []byte("this is the OLD version of the audio file")
|
||||
oldMD5 := md5Hex(oldContent)
|
||||
newContent := []byte("this is the NEW version of the audio file")
|
||||
newMD5 := md5Hex(newContent)
|
||||
|
||||
track := db.AudioTrack{
|
||||
Filename: "174Hz.wav",
|
||||
Name: "174 Hz",
|
||||
AssignedFrequency: 174,
|
||||
MD5: oldMD5,
|
||||
Selected: true,
|
||||
Order: 2,
|
||||
}
|
||||
if err := gdb.Create(&track).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldPath := filepath.Join(audioDir, "174Hz.wav")
|
||||
if err := os.WriteFile(oldPath, oldContent, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/checksums.md5":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, "%s 174Hz.wav\n", newMD5)
|
||||
case "/174Hz.wav":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(newContent)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
progress := make(chan download.DownloadStatus, 100)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
download.DownloadAudioFiles(progress, gdb, audioDir, server.URL+"/")
|
||||
close(done)
|
||||
}()
|
||||
for range progress {
|
||||
}
|
||||
<-done
|
||||
|
||||
newPath := filepath.Join(audioDir, "174Hz.wav")
|
||||
gotContent, err := os.ReadFile(newPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reading new file: %v", err)
|
||||
}
|
||||
if string(gotContent) != string(newContent) {
|
||||
t.Errorf("new file content = %q, want %q", string(gotContent), string(newContent))
|
||||
}
|
||||
|
||||
var updated db.AudioTrack
|
||||
if err := gdb.First(&updated, track.ID).Error; err != nil {
|
||||
t.Fatalf("fetching updated track: %v", err)
|
||||
}
|
||||
if updated.MD5 != newMD5 {
|
||||
t.Errorf("DB MD5 = %q, want %q", updated.MD5, newMD5)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadOrphanRemoval verifies that files and DB records not in the
|
||||
// manifest are removed.
|
||||
func TestDownloadOrphanRemoval(t *testing.T) {
|
||||
audioDir := t.TempDir()
|
||||
dbDir := t.TempDir()
|
||||
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(dbDir, "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := gdb.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
|
||||
if err := gdb.AutoMigrate(&db.AudioTrack{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
orphanPath := filepath.Join(audioDir, "orphan.wav")
|
||||
if err := os.WriteFile(orphanPath, []byte("orphan"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
gdb.Create(&db.AudioTrack{Filename: "ghost.wav", Name: "Ghost Track", Order: 99})
|
||||
|
||||
keptContent := []byte("kept file")
|
||||
keptMD5 := md5Hex(keptContent)
|
||||
keptPath := filepath.Join(audioDir, "kept.wav")
|
||||
if err := os.WriteFile(keptPath, keptContent, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/checksums.md5":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, "%s kept.wav\n", keptMD5)
|
||||
case "/kept.wav":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(keptContent)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
progress := make(chan download.DownloadStatus, 100)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
download.DownloadAudioFiles(progress, gdb, audioDir, server.URL+"/")
|
||||
close(done)
|
||||
}()
|
||||
for range progress {
|
||||
}
|
||||
<-done
|
||||
|
||||
if _, err := os.Stat(orphanPath); !os.IsNotExist(err) {
|
||||
t.Errorf("orphan file %s should have been removed", orphanPath)
|
||||
}
|
||||
|
||||
var count int64
|
||||
gdb.Model(&db.AudioTrack{}).Where("filename = ?", "ghost.wav").Count(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("orphan DB record ghost.wav should have been deleted, count=%d", count)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(keptPath); os.IsNotExist(err) {
|
||||
t.Errorf("kept file %s should still exist after download", keptPath)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user