Add schedule sync and schedule-aware playback
This commit is contained in:
@@ -221,6 +221,86 @@ bool parseCreateAccountJson(const std::string& body, AccountBody& out) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── GET /schedule ─────────────────────────────────────────────────────
|
||||
|
||||
bool parseScheduleJson(const std::string& body, ScheduleResponse& out) {
|
||||
radiantwave::json::JsonValue root;
|
||||
if (!radiantwave::json::parseDocument(body, root) ||
|
||||
root.type != radiantwave::json::JsonValue::Type::Object) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ScheduleResponse parsed;
|
||||
parsed.email = root.stringField("email");
|
||||
|
||||
const radiantwave::json::JsonValue* blocks = root.find("blocks");
|
||||
if (blocks && blocks->type == radiantwave::json::JsonValue::Type::Array) {
|
||||
for (const auto& entry : blocks->array) {
|
||||
if (entry.type != radiantwave::json::JsonValue::Type::Object) continue;
|
||||
|
||||
ScheduleBlock block;
|
||||
block.id = entry.stringField("id");
|
||||
if (block.id.empty()) continue;
|
||||
|
||||
block.start_minutes = static_cast<int>(entry.numberField("startMinutes"));
|
||||
block.end_minutes = static_cast<int>(entry.numberField("endMinutes"));
|
||||
block.overnight = entry.boolField("overnight");
|
||||
block.duration_minutes = static_cast<int>(entry.numberField("durationMinutes"));
|
||||
|
||||
// `dynamicAudio` is optional on the wire and defaults to true.
|
||||
const radiantwave::json::JsonValue* dyn = entry.find("dynamicAudio");
|
||||
block.dynamic_audio =
|
||||
(dyn && dyn->type == radiantwave::json::JsonValue::Type::Bool) ? dyn->b : true;
|
||||
|
||||
const radiantwave::json::JsonValue* ids = entry.find("proclamationIds");
|
||||
if (ids && ids->type == radiantwave::json::JsonValue::Type::Array) {
|
||||
for (const auto& id : ids->array) {
|
||||
if (id.type == radiantwave::json::JsonValue::Type::String &&
|
||||
!id.str.empty()) {
|
||||
block.proclamation_ids.push_back(id.str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parsed.blocks.push_back(std::move(block));
|
||||
}
|
||||
}
|
||||
|
||||
out = std::move(parsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Percent-encodes a query-parameter value (RFC 3986 unreserved set).
|
||||
std::string urlEncode(const std::string& value) {
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) return value;
|
||||
char* escaped = curl_easy_escape(curl, value.c_str(), static_cast<int>(value.size()));
|
||||
std::string result = escaped ? escaped : value;
|
||||
if (escaped) curl_free(escaped);
|
||||
curl_easy_cleanup(curl);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
bool fetchSchedule(const std::string& email, const std::string& license_key,
|
||||
ScheduleResponse& out) {
|
||||
if (email.empty() || license_key.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string path = std::string(PATH_SCHEDULE) +
|
||||
"?email=" + urlEncode(email) +
|
||||
"&licenseKey=" + urlEncode(license_key);
|
||||
HttpResponse response = httpGet(path);
|
||||
if (!response.transportOk || response.status < 200 || response.status >= 300) {
|
||||
return false;
|
||||
}
|
||||
return parseScheduleJson(response.body, out);
|
||||
}
|
||||
|
||||
AccountResult createAccount(const std::string& email, const std::string& name) {
|
||||
AccountResult result;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ constexpr const char* PATH_FREQUENCIES = "/frequencies";
|
||||
constexpr const char* PATH_LICENSE = "/license";
|
||||
constexpr const char* PATH_CREATE_ACCOUNT = "/automations/create/account";
|
||||
constexpr const char* PATH_COLOR_PALETTES = "/color-palettes";
|
||||
constexpr const char* PATH_SCHEDULE = "/schedule";
|
||||
|
||||
// Result of a single HTTP request.
|
||||
struct HttpResponse {
|
||||
@@ -119,4 +120,37 @@ struct AccountResult {
|
||||
// to the user and is NOT part of the response. `name` may be empty.
|
||||
AccountResult createAccount(const std::string& email, const std::string& name = "");
|
||||
|
||||
// ─── GET /schedule ─────────────────────────────────────────────────────
|
||||
|
||||
// One scheduled listening block. Wall-clock times are carried as minutes from
|
||||
// midnight; overnight blocks wrap past midnight (end clock time <= start).
|
||||
struct ScheduleBlock {
|
||||
std::string id;
|
||||
int start_minutes = 0;
|
||||
int end_minutes = 0;
|
||||
bool overnight = false;
|
||||
int duration_minutes = 0;
|
||||
// When true the kiosk enables dynamic audio for the block's duration,
|
||||
// overriding the device's audio selection. Defaults to true: schedules are
|
||||
// dynamic unless the portal explicitly says otherwise.
|
||||
bool dynamic_audio = true;
|
||||
// Selected proclamation ids, in play order.
|
||||
std::vector<std::string> proclamation_ids;
|
||||
};
|
||||
|
||||
struct ScheduleResponse {
|
||||
std::string email;
|
||||
std::vector<ScheduleBlock> blocks;
|
||||
};
|
||||
|
||||
// Parse the JSON payload returned by the schedule endpoint. `dynamicAudio` is
|
||||
// optional and defaults to true when absent. Returns false on malformed input.
|
||||
bool parseScheduleJson(const std::string& body, ScheduleResponse& out);
|
||||
|
||||
// Fetch a user's schedule. Authenticates via the paired `email` + `license_key`
|
||||
// query parameters. Returns true only on HTTP 2xx + successful parse; failures
|
||||
// (including a missing/blank pair) leave `out` untouched.
|
||||
bool fetchSchedule(const std::string& email, const std::string& license_key,
|
||||
ScheduleResponse& out);
|
||||
|
||||
} // namespace radiantwave::api
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "network/proclamation_sync.h"
|
||||
#include "db/db_keys.h"
|
||||
#include "network/api_client.h"
|
||||
#include "network/json.h"
|
||||
|
||||
@@ -161,9 +162,41 @@ bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
|
||||
if (!db.syncProclamations(proclamations, defaultProclamationId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Best-effort: a schedule fetch/store failure must never fail the
|
||||
// proclamation sync or roll back the data just written.
|
||||
syncScheduleFromApi(db);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool syncScheduleFromApi(radiantwave::db::DatabaseManager& db) {
|
||||
const std::string email = db.getKV(radiantwave::db::KV_EMAIL_ADDRESS);
|
||||
const std::string license_key = db.getKV(radiantwave::db::KV_LICENSE_KEY);
|
||||
if (email.empty() || license_key.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
api::ScheduleResponse schedule;
|
||||
if (!api::fetchSchedule(email, license_key, schedule)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<radiantwave::db::ScheduleBlockRecord> records;
|
||||
records.reserve(schedule.blocks.size());
|
||||
for (const auto& block : schedule.blocks) {
|
||||
radiantwave::db::ScheduleBlockRecord rec;
|
||||
rec.id = block.id;
|
||||
rec.start_minutes = block.start_minutes;
|
||||
rec.end_minutes = block.end_minutes;
|
||||
rec.overnight = block.overnight;
|
||||
rec.duration_minutes = block.duration_minutes;
|
||||
rec.dynamic_audio = block.dynamic_audio;
|
||||
rec.proclamation_ids = block.proclamation_ids;
|
||||
records.push_back(std::move(rec));
|
||||
}
|
||||
return db.replaceScheduleBlocks(records);
|
||||
}
|
||||
|
||||
// ─── Color palettes (readiness code; not wired into the app flow) ─────
|
||||
|
||||
bool parseProclamationPalettesJson(const std::string& body,
|
||||
|
||||
@@ -23,9 +23,18 @@ bool parseGroupsJson(const std::string& body,
|
||||
// the DB, preserving any existing selection and forcing `defaultProclamationId`
|
||||
// to stay selected. Returns true only if the API sync succeeded; on failure the
|
||||
// database is left untouched so the app can fall back to cached data.
|
||||
//
|
||||
// Also requests the user's schedule and replaces the stored schedule when the
|
||||
// device has an email + license key. Schedule sync is best-effort: a schedule
|
||||
// failure never fails (or rolls back) the proclamation sync.
|
||||
bool syncProclamationsFromApi(radiantwave::db::DatabaseManager& db,
|
||||
const std::string& defaultProclamationId);
|
||||
|
||||
// Fetch GET /schedule for the device's paired email + license key and replace
|
||||
// the stored schedule blocks. Skips (returns false) when either credential is
|
||||
// missing. Returns true only on HTTP 2xx + successful parse + successful store.
|
||||
bool syncScheduleFromApi(radiantwave::db::DatabaseManager& db);
|
||||
|
||||
// Parse color palettes embedded in the proclamations JSON payload. Each
|
||||
// proclamation object may carry a "colorPalette" array of "#RRGGBB" strings;
|
||||
// the palette is keyed by the proclamation's solfeggioFrequencyId. When
|
||||
|
||||
Reference in New Issue
Block a user