#include "audio/audio_downloader.h" #include "db/database_manager.h" #include "logger/logger.h" #include #include #include #include #include #include #include #include #include #include namespace radiantwave::audio { namespace { constexpr const char* AUDIO_DIR_SUFFIX = "/.local/radiantwave/audio/"; constexpr const char* USER_AGENT = "RadiantWave-Kiosk/0.1"; std::string audioDir() { const char* home = std::getenv("HOME"); return std::string(home && *home ? home : "/tmp") + AUDIO_DIR_SUFFIX; } // Strips the "*" binary-mode marker and any "./" prefixes md5sum emits. std::string stripManifestPrefix(std::string name) { if (!name.empty() && name.front() == '*') name.erase(0, 1); while (name.compare(0, 2, "./") == 0) name.erase(0, 2); return name; } // Produces a friendly display name from a bare filename for legacy manifests // that carry no display name. std::string humanizeFilename(const std::string& filename) { std::string name = filename; size_t dot = name.rfind('.'); if (dot != std::string::npos) name.erase(dot); for (char& c : name) { if (c == '_') c = ' '; } return name; } // Tokenizes a manifest line, honoring a single-quoted display name that may // contain spaces. std::vector tokenizeManifestLine(const std::string& line) { std::vector tokens; size_t i = 0; const size_t n = line.size(); while (i < n) { while (i < n && std::isspace(static_cast(line[i]))) ++i; if (i >= n) break; if (line[i] == '\'') { size_t close = line.find('\'', i + 1); if (close == std::string::npos) break; tokens.push_back(line.substr(i + 1, close - i - 1)); i = close + 1; } else { size_t start = i; while (i < n && !std::isspace(static_cast(line[i]))) ++i; tokens.push_back(line.substr(start, i - start)); } } return tokens; } // ─── HTTP helpers (libcurl) ───────────────────────────────────────────── bool httpGetString(const std::string& url, std::string& body) { CURL* curl = curl_easy_init(); if (!curl) return false; curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L); curl_easy_setopt(curl, CURLOPT_USERAGENT, USER_AGENT); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +[](char* ptr, size_t size, size_t nmemb, void* userdata) -> size_t { auto* out = static_cast(userdata); out->append(ptr, size * nmemb); return size * nmemb; }); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body); CURLcode res = curl_easy_perform(curl); long status = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); curl_easy_cleanup(curl); return res == CURLE_OK && status >= 200 && status < 300; } bool httpGetFile(const std::string& url, const std::string& path) { CURL* curl = curl_easy_init(); if (!curl) return false; std::FILE* f = std::fopen(path.c_str(), "wb"); if (!f) { curl_easy_cleanup(curl); return false; } curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 120L); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 15L); curl_easy_setopt(curl, CURLOPT_USERAGENT, USER_AGENT); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +[](char* ptr, size_t size, size_t nmemb, void* userdata) -> size_t { return std::fwrite(ptr, size, nmemb, static_cast(userdata)); }); curl_easy_setopt(curl, CURLOPT_WRITEDATA, f); CURLcode res = curl_easy_perform(curl); long status = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); curl_easy_cleanup(curl); std::fclose(f); return res == CURLE_OK && status >= 200 && status < 300; } } // anonymous namespace // ─── Public pure helpers ──────────────────────────────────────────────── // Frequency value (Hz) -> internal solfeggio frequency id. Mirrors the id<->Hz // relationships documented in DatabaseManager::seedColorPalettes. int frequencyIdForHz(int hz) { switch (hz) { case 174: case 285: return 1; case 396: return 2; case 417: return 3; case 432: return 4; case 528: return 5; case 639: return 6; case 741: return 7; case 852: return 8; case 963: return 9; default: return 0; } } int hzFromFilename(const std::string& filename) { size_t i = 0; while (i < filename.size() && filename[i] >= '0' && filename[i] <= '9') ++i; if (i == 0) return 0; // The digits must be a frequency: "396Hz.wav", "14Hz_Binaural.wav". if (i + 2 <= filename.size() && (filename[i] == 'H' || filename[i] == 'h') && (filename[i + 1] == 'Z' || filename[i + 1] == 'z')) { int hz = 0; for (size_t d = 0; d < i; ++d) { hz = hz * 10 + (filename[d] - '0'); } return hz; } return 0; } bool parseManifest(const std::string& text, std::vector& out) { std::vector result; std::istringstream stream(text); std::string line; while (std::getline(stream, line)) { size_t start = line.find_first_not_of(" \t\r\n"); if (start == std::string::npos) continue; size_t end = line.find_last_not_of(" \t\r\n"); line = line.substr(start, end - start + 1); if (line.empty() || line.front() == '#') continue; std::vector tokens = tokenizeManifestLine(line); if (tokens.empty()) continue; AudioManifestEntry entry; if (tokens.size() == 4) { // Current format: '' entry.filename = stripManifestPrefix(tokens[0]); entry.display_name = tokens[1]; entry.solfeggio_frequency_hz = std::atoi(tokens[2].c_str()); entry.md5 = tokens[3]; if (entry.filename.empty() || entry.md5.size() != 32) continue; // Tolerant parse: honor the Hz value only when it maps to a known // frequency (a transitional manifest may carry an id, a typo may // carry garbage); otherwise fall back to the Hz from the filename. if (frequencyIdForHz(entry.solfeggio_frequency_hz) == 0) { int fromFilename = hzFromFilename(entry.filename); if (fromFilename != 0) { entry.solfeggio_frequency_hz = fromFilename; } } result.push_back(std::move(entry)); } else if (tokens.size() == 2 && tokens[0].size() == 32) { // Legacy format: entry.md5 = tokens[0]; entry.filename = stripManifestPrefix(tokens[1]); entry.display_name = humanizeFilename(entry.filename); entry.solfeggio_frequency_hz = hzFromFilename(entry.filename); if (entry.filename.empty()) continue; result.push_back(std::move(entry)); } // else: malformed line, skip } if (result.empty()) return false; out = std::move(result); return true; } std::string computeFileMd5(const std::string& path) { std::FILE* f = std::fopen(path.c_str(), "rb"); if (!f) return std::string(); EVP_MD_CTX* ctx = EVP_MD_CTX_new(); if (!ctx) { std::fclose(f); return std::string(); } if (EVP_DigestInit_ex(ctx, EVP_md5(), nullptr) != 1) { EVP_MD_CTX_free(ctx); std::fclose(f); return std::string(); } unsigned char buf[8192]; size_t n; while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) { if (EVP_DigestUpdate(ctx, buf, n) != 1) { EVP_MD_CTX_free(ctx); std::fclose(f); return std::string(); } } bool readError = std::ferror(f) != 0; std::fclose(f); if (readError) { EVP_MD_CTX_free(ctx); return std::string(); } unsigned char digest[EVP_MAX_MD_SIZE]; unsigned int digestLen = 0; if (EVP_DigestFinal_ex(ctx, digest, &digestLen) != 1) { EVP_MD_CTX_free(ctx); return std::string(); } EVP_MD_CTX_free(ctx); static const char* hexDigits = "0123456789abcdef"; std::string hex; hex.reserve(digestLen * 2); for (unsigned int i = 0; i < digestLen; ++i) { hex += hexDigits[digest[i] >> 4]; hex += hexDigits[digest[i] & 0x0f]; } return hex; } bool buildAudioDownloadWarning(const AudioDownloadSnapshot& snapshot, bool dynamicAudio, const std::string& selectedFilename, std::string& message) { if (dynamicAudio) { if (snapshot.allAvailable) return false; message = "You currently have 'Dynamic Audio' selected, but some audio " "files are still being downloaded. Some audio will not play until " "these files have finished downloading."; return true; } for (const auto& file : snapshot.files) { if (file.filename != selectedFilename) continue; if (file.available) return false; const std::string name = file.display_name.empty() ? file.filename : file.display_name; message = "You currently have '" + name + "' selected, but it is still being downloaded. No audio " "will play until this file has finished downloading."; return true; } // The selection is not part of the manifest (the manifest may not be known // yet), so the downloader cannot confirm whether it is still downloading. return false; } // ─── Lifecycle ────────────────────────────────────────────────────────── AudioDownloader::~AudioDownloader() { shutdown(); } void AudioDownloader::start(db::DatabaseManager& db) { std::lock_guard lock(mutex_); if (worker_.joinable()) return; db_ = &db; stop_ = false; worker_ = std::thread(&AudioDownloader::run, this); } void AudioDownloader::shutdown() { { std::lock_guard lock(mutex_); stop_ = true; } cv_.notify_all(); if (worker_.joinable()) worker_.join(); } std::string AudioDownloader::statusString() const { std::lock_guard lock(mutex_); return status_; } AudioDownloadSnapshot AudioDownloader::snapshot() const { std::lock_guard lock(mutex_); return snapshot_; } void AudioDownloader::publishSnapshot( const std::vector& manifest, const std::unordered_set& availableFilenames) { AudioDownloadSnapshot snap; snap.manifestKnown = true; snap.allAvailable = !manifest.empty(); snap.files.reserve(manifest.size()); for (const auto& entry : manifest) { const bool available = availableFilenames.count(entry.filename) != 0; const std::string displayName = entry.display_name.empty() ? humanizeFilename(entry.filename) : entry.display_name; snap.files.push_back({entry.filename, displayName, available}); if (!available) snap.allAvailable = false; } std::lock_guard lock(mutex_); snapshot_ = std::move(snap); } void AudioDownloader::setStatus(const std::string& msg) { { std::lock_guard lock(mutex_); status_ = msg; } cv_.notify_all(); } void AudioDownloader::wait(std::chrono::seconds seconds) { std::unique_lock lock(mutex_); cv_.wait_for(lock, seconds, [this]() { return stop_.load(); }); } // ─── Worker loop ──────────────────────────────────────────────────────── void AudioDownloader::run() { logger::Logger::instance().info("Audio", "Audio downloader started"); setStatus("Checking for audio updates..."); while (!stop_.load()) { std::vector manifest; if (!fetchManifest(manifest)) { setStatus("Waiting for internet..."); wait(std::chrono::seconds(5)); continue; } std::string dir = audioDir(); std::unordered_map orderByFilename; for (size_t i = 0; i < manifest.size(); ++i) { orderByFilename[manifest[i].filename] = static_cast(i + 1); } // `available` is the set of files actually present on disk: files // whose checksum matches, plus files present but stale (they stay // listed while being re-downloaded). Files that don't exist yet are // excluded so the settings list reflects live download progress. std::vector queue; std::vector available; std::unordered_set listed; available.reserve(manifest.size()); auto addAvailable = [&](const AudioManifestEntry& entry) { if (!listed.insert(entry.filename).second) return; available.push_back({0, entry.display_name, entry.filename, orderByFilename[entry.filename], frequencyIdForHz(entry.solfeggio_frequency_hz)}); }; // Publishes the current on-disk set to the database so the audio // settings list updates while the user is viewing it. auto syncNow = [&]() { if (db_ && !available.empty()) db_->syncAudioFiles(available); }; for (const auto& entry : manifest) { std::string localMd5 = computeFileMd5(dir + entry.filename); if (localMd5 == entry.md5) { addAvailable(entry); } else if (!localMd5.empty()) { logger::Logger::instance().warning( "Audio", "Checksum mismatch for " + entry.filename + ", re-downloading"); addAvailable(entry); queue.push_back(entry); } else { queue.push_back(entry); } } size_t toDownload = queue.size(); size_t downloaded = 0; // Publish what is (and is not) on disk before downloads begin so the // UI can warn immediately, then again as each file lands. publishSnapshot(manifest, listed); if (!queue.empty()) { std::error_code ec; std::filesystem::create_directories(dir, ec); for (const auto& work : queue) { if (stop_.load()) break; setStatus("Downloading audio files (" + std::to_string(downloaded + 1) + " / " + std::to_string(queue.size()) + ")..."); if (!downloadAndVerify(work.filename, work.md5, work.display_name)) break; addAvailable(work); ++downloaded; // Publish immediately so a file that just finished downloading // appears in the list without waiting for the rest. syncNow(); publishSnapshot(manifest, listed); } } // Remove leftover audio files that are no longer in the manifest. std::error_code ec; if (std::filesystem::exists(dir)) { for (auto it = std::filesystem::directory_iterator(dir, ec), end = std::filesystem::directory_iterator(); !ec && it != end; it.increment(ec)) { if (!it->is_regular_file(ec)) continue; std::string name = it->path().filename().string(); if (name.empty() || name.front() == '.' || name.size() >= 5 && name.compare(name.size() - 5, 5, ".part") == 0) { continue; } if (orderByFilename.count(name) == 0) { logger::Logger::instance().debug("Audio", "Removing leftover " + name); std::filesystem::remove(it->path(), ec); } } } // Final reconcile so rows for files removed from disk are dropped too. // Runs even when nothing is available, so an empty on-disk set clears // any stale rows. if (db_) db_->syncAudioFiles(available); bool upToDate = downloaded == toDownload; if (upToDate && !stop_.load()) { setStatus("Audio files up to date"); wait(std::chrono::seconds(60)); } else { wait(std::chrono::seconds(5)); } } logger::Logger::instance().info("Audio", "Audio downloader stopped"); } bool AudioDownloader::fetchManifest(std::vector& out) { std::string body; std::string url = std::string(AUDIO_BASE_URL) + MANIFEST_FILENAME; if (!httpGetString(url, body)) { logger::Logger::instance().debug("Audio", "Failed to fetch audio manifest"); return false; } if (!parseManifest(body, out)) { logger::Logger::instance().warning("Audio", "Audio manifest was unparseable"); return false; } return true; } bool AudioDownloader::downloadAndVerify(const std::string& filename, const std::string& expectedMd5, const std::string& displayName) { std::string dir = audioDir(); std::string destPath = dir + filename; std::string tmpPath = destPath + ".part"; std::error_code ec; std::filesystem::remove(tmpPath, ec); if (!httpGetFile(std::string(AUDIO_BASE_URL) + filename, tmpPath)) { logger::Logger::instance().warning("Audio", "Download failed for " + filename); std::filesystem::remove(tmpPath, ec); return false; } std::string gotMd5 = computeFileMd5(tmpPath); if (gotMd5 != expectedMd5) { logger::Logger::instance().warning( "Audio", "Checksum mismatch after download for " + filename); std::filesystem::remove(tmpPath, ec); return false; } std::filesystem::rename(tmpPath, destPath, ec); if (ec) { logger::Logger::instance().warning("Audio", "Could not move " + filename + " into place"); std::filesystem::remove(tmpPath, ec); return false; } logger::Logger::instance().info("Audio", "Downloaded and verified " + displayName); return true; } } // namespace radiantwave::audio