+10
-27
@@ -50,7 +50,7 @@ auto invalid(std::string message) -> modulus::error {
|
||||
}
|
||||
|
||||
auto usage() -> std::string {
|
||||
return "usage: vamp-zero [--config PATH] [--check-config] [--health-check] [--version] [--once] [--ensure-database]";
|
||||
return "usage: vamp-zero [--config PATH] [--check-config] [--health-check] [--version] [--once]";
|
||||
}
|
||||
|
||||
auto bind_port(vamp_zero::config const &config) -> std::uint16_t {
|
||||
@@ -134,9 +134,9 @@ auto write_heartbeat(vamp_zero::config const &config) -> std::expected<void, mod
|
||||
return write_text_atomic(config.runtime.heartbeat_path, boost::json::serialize(heartbeat));
|
||||
}
|
||||
|
||||
auto ensure_database(vamp_zero::config const &config) -> std::expected<void, modulus::error> {
|
||||
auto validate_database(vamp_zero::config const &config, std::bool_t integrity) -> std::expected<void, modulus::error> {
|
||||
auto io = boost::asio::io_context{};
|
||||
auto future = boost::asio::co_spawn(io, vamp_zero::async_ensure_database(io.get_executor(), config.database), boost::asio::use_future);
|
||||
auto future = boost::asio::co_spawn(io, vamp_zero::async_validate_database(io.get_executor(), config.database, integrity), boost::asio::use_future);
|
||||
io.run();
|
||||
return future.get();
|
||||
}
|
||||
@@ -152,17 +152,10 @@ auto make_server_endpoint(vamp_zero::config const &config) -> std::expected<ligo
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
auto import_library(vamp_zero::config const &config) -> std::expected<void, modulus::error> {
|
||||
auto io = boost::asio::io_context{};
|
||||
auto future = boost::asio::co_spawn(io, vamp_zero::async_import_catalogs(io.get_executor(), config.database, config.runtime.assets_path), boost::asio::use_future);
|
||||
io.run();
|
||||
return future.get();
|
||||
}
|
||||
|
||||
auto load_initial_state(vamp_zero::config const &config) -> std::expected<vamp_zero::app_state, modulus::error> {
|
||||
auto io = boost::asio::io_context{};
|
||||
auto performance_future = boost::asio::co_spawn(io, vamp_zero::async_load_performance_library(io.get_executor(), config.database), boost::asio::use_future);
|
||||
auto practice_future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config.database), boost::asio::use_future);
|
||||
auto performance_future = boost::asio::co_spawn(io, vamp_zero::async_load_performance_library(io.get_executor(), config.database, config.runtime.assets_path), boost::asio::use_future);
|
||||
auto practice_future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config.database, config.runtime.assets_path), boost::asio::use_future);
|
||||
io.run();
|
||||
auto performance = performance_future.get();
|
||||
if (!performance) {
|
||||
@@ -194,7 +187,7 @@ auto load_initial_state(vamp_zero::config const &config) -> std::expected<vamp_z
|
||||
}
|
||||
|
||||
auto validate_score_pages(boost::asio::any_io_executor executor, vamp_zero::config const &config, vamp_zero::score_reference reference) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
auto score = co_await vamp_zero::async_load_score(executor, config.database, std::move(reference));
|
||||
auto score = co_await vamp_zero::async_load_score(executor, config.database, std::move(reference), config.runtime.assets_path);
|
||||
if (!score) {
|
||||
co_return std::unexpected(score.error());
|
||||
}
|
||||
@@ -348,7 +341,7 @@ auto run_headless(vamp_zero::config const &config, vamp_zero::control_state &sta
|
||||
if (audio != nullptr) {
|
||||
audio->sync(state.snapshot());
|
||||
}
|
||||
auto loader = std::make_shared<vamp_zero::score_loader>(io.get_executor(), [executor = io.get_executor(), database = config.database](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference)); });
|
||||
auto loader = std::make_shared<vamp_zero::score_loader>(io.get_executor(), [executor = io.get_executor(), database = config.database, assets = config.runtime.assets_path](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference), assets); });
|
||||
auto playback = playback_poll{io, state, audio, loader, config.runtime.assets_path, true};
|
||||
auto heartbeat = write_heartbeat(config);
|
||||
if (!heartbeat) {
|
||||
@@ -371,7 +364,7 @@ auto run_display(vamp_zero::config const &config, vamp_zero::control_state &stat
|
||||
if (!endpoint) {
|
||||
return std::unexpected(endpoint.error());
|
||||
}
|
||||
auto ui = vamp_zero::zero_ui{io.get_executor(), config.display, config.database, [&state](vamp_protocol::page_command command) {
|
||||
auto ui = vamp_zero::zero_ui{io.get_executor(), config.display, config.database, config.runtime.assets_path, [&state](vamp_protocol::page_command command) {
|
||||
static_cast<void>(state.apply(command));
|
||||
}};
|
||||
state.set_observer([&ui, &config](vamp_zero::app_state app) {
|
||||
@@ -468,8 +461,6 @@ auto vamp_zero::parse_command_line(std::int32_t argc, std::char_t **argv) -> std
|
||||
result.print_version = true;
|
||||
} else if (argument == "--once") {
|
||||
result.exit_after_startup = true;
|
||||
} else if (argument == "--ensure-database") {
|
||||
result.ensure_database = true;
|
||||
} else if (argument == "--help" || argument == "-h") {
|
||||
return std::unexpected(invalid(usage()));
|
||||
} else {
|
||||
@@ -479,8 +470,7 @@ auto vamp_zero::parse_command_line(std::int32_t argc, std::char_t **argv) -> std
|
||||
auto command_count = static_cast<std::uint32_t>(result.check_config ? 1 : 0) +
|
||||
static_cast<std::uint32_t>(result.health_check ? 1 : 0) +
|
||||
static_cast<std::uint32_t>(result.print_version ? 1 : 0) +
|
||||
static_cast<std::uint32_t>(result.exit_after_startup ? 1 : 0) +
|
||||
static_cast<std::uint32_t>(result.ensure_database ? 1 : 0);
|
||||
static_cast<std::uint32_t>(result.exit_after_startup ? 1 : 0);
|
||||
if (command_count > 1U) {
|
||||
return std::unexpected(invalid("only one command flag may be selected"));
|
||||
}
|
||||
@@ -509,17 +499,10 @@ auto vamp_zero::run(vamp_zero::command_line command) -> std::expected<void, modu
|
||||
std::cout << "ok\n";
|
||||
return {};
|
||||
}
|
||||
if (command.ensure_database) {
|
||||
return ensure_database(*config);
|
||||
}
|
||||
auto ensured = ensure_database(*config);
|
||||
auto ensured = validate_database(*config, command.exit_after_startup || config->runtime.exit_after_startup);
|
||||
if (!ensured) {
|
||||
return ensured;
|
||||
}
|
||||
auto imported = import_library(*config);
|
||||
if (!imported) {
|
||||
return imported;
|
||||
}
|
||||
auto initial = load_initial_state(*config);
|
||||
if (!initial) {
|
||||
return std::unexpected(initial.error());
|
||||
|
||||
+63
-525
@@ -1,10 +1,10 @@
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <map>
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
@@ -25,16 +25,13 @@
|
||||
namespace {
|
||||
|
||||
constexpr auto application_id = std::int64_t{0x56414D50};
|
||||
constexpr auto catalogue_schema_version = std::int64_t{1};
|
||||
|
||||
auto connection_config(vamp_zero::database_config const &config) -> noto::connection_config {
|
||||
auto result = config.path == ":memory:" ? noto::memory_connection_config("product-vamp")
|
||||
: noto::file_connection_config(config.path, "product-vamp");
|
||||
auto result = noto::file_connection_config(config.path, "product-vamp");
|
||||
result.mode = noto::open_mode::READ_ONLY;
|
||||
result.busy_timeout = std::chrono::seconds{5};
|
||||
result.foreign_keys = true;
|
||||
result.pragmas = {
|
||||
{.name = "synchronous", .value = std::string{"NORMAL"}},
|
||||
{.name = "temp_store", .value = std::string{"MEMORY"}},
|
||||
};
|
||||
result.pragmas = {{.name = "query_only", .value = true}};
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -61,243 +58,30 @@ auto close_catalogue_query(noto::connection &connection, noto::statement &statem
|
||||
co_return status;
|
||||
}
|
||||
|
||||
auto execute_schema_statement(noto::connection &connection, std::string statement) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
auto result = co_await connection.async_execute(std::move(statement));
|
||||
if (!result) {
|
||||
co_return std::unexpected(result.error());
|
||||
}
|
||||
co_return std::expected<void, modulus::error>{};
|
||||
}
|
||||
|
||||
auto column_exists(noto::connection &connection, std::string table, std::string column) -> boost::asio::awaitable<std::expected<std::bool_t, modulus::error>> {
|
||||
auto selected = co_await connection.async_execute("PRAGMA table_info(" + table + ")");
|
||||
if (!selected) {
|
||||
co_return std::unexpected(selected.error());
|
||||
}
|
||||
for (auto const &row : selected->rows) {
|
||||
if (row.size() >= 2U && text_value(row[1]) == column) {
|
||||
co_return true;
|
||||
}
|
||||
}
|
||||
co_return false;
|
||||
}
|
||||
|
||||
auto ensure_column(noto::connection &connection, std::string table, std::string column, std::string alter_statement) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
auto exists = co_await column_exists(connection, std::move(table), std::move(column));
|
||||
if (!exists) {
|
||||
co_return std::unexpected(exists.error());
|
||||
}
|
||||
if (*exists) {
|
||||
co_return std::expected<void, modulus::error>{};
|
||||
}
|
||||
co_return co_await execute_schema_statement(connection, std::move(alter_statement));
|
||||
}
|
||||
|
||||
auto ensure_current_schema(noto::connection &connection) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
auto begin = co_await connection.async_execute("BEGIN IMMEDIATE");
|
||||
if (!begin) {
|
||||
co_return std::unexpected(begin.error());
|
||||
}
|
||||
auto ensured = std::expected<void, modulus::error>{};
|
||||
auto schema_statements = std::vector<std::string>{
|
||||
R"sql(
|
||||
CREATE TABLE IF NOT EXISTS songs (
|
||||
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 120),
|
||||
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
|
||||
composer TEXT NOT NULL DEFAULT '',
|
||||
source_json TEXT NOT NULL CHECK(json_valid(source_json)),
|
||||
created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0),
|
||||
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms),
|
||||
part TEXT NOT NULL DEFAULT '',
|
||||
source_path TEXT NOT NULL DEFAULT '',
|
||||
key_signature TEXT NOT NULL DEFAULT '',
|
||||
catalog_managed INTEGER NOT NULL DEFAULT 1 CHECK(catalog_managed IN (0, 1))
|
||||
) STRICT
|
||||
)sql",
|
||||
R"sql(
|
||||
CREATE TABLE IF NOT EXISTS page_state (
|
||||
id INTEGER PRIMARY KEY CHECK(id = 1),
|
||||
active_song_id TEXT NOT NULL DEFAULT '',
|
||||
page_index INTEGER NOT NULL DEFAULT 0 CHECK(page_index >= 0),
|
||||
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= 0)
|
||||
) STRICT
|
||||
)sql",
|
||||
R"sql(
|
||||
CREATE TABLE IF NOT EXISTS practice_exercises (
|
||||
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 160),
|
||||
practice_type_id TEXT NOT NULL DEFAULT 'general' CHECK(length(trim(practice_type_id)) BETWEEN 1 AND 120),
|
||||
practice_type_title TEXT NOT NULL DEFAULT 'General Practice' CHECK(length(trim(practice_type_title)) BETWEEN 1 AND 200),
|
||||
lesson_group_id TEXT NOT NULL DEFAULT 'general' CHECK(length(trim(lesson_group_id)) BETWEEN 1 AND 120),
|
||||
lesson_group_title TEXT NOT NULL DEFAULT 'General' CHECK(length(trim(lesson_group_title)) BETWEEN 1 AND 200),
|
||||
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
|
||||
composer TEXT NOT NULL DEFAULT '',
|
||||
key_signature TEXT NOT NULL DEFAULT 'C',
|
||||
source_json TEXT NOT NULL CHECK(json_valid(source_json)),
|
||||
source_path TEXT NOT NULL DEFAULT '',
|
||||
created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0),
|
||||
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms),
|
||||
catalog_managed INTEGER NOT NULL DEFAULT 1 CHECK(catalog_managed IN (0, 1))
|
||||
) STRICT
|
||||
)sql",
|
||||
"DROP TABLE IF EXISTS _noto_schema_migrations",
|
||||
auto resolve_source_path(std::string const &assets_path, std::string const &source) -> std::expected<std::string, modulus::error> {
|
||||
auto fail = [] {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "unsafe catalogue asset path; rebuild or reinstall Product Vamp"));
|
||||
};
|
||||
for (auto &statement : schema_statements) {
|
||||
if (!ensured) {
|
||||
break;
|
||||
}
|
||||
ensured = co_await execute_schema_statement(connection, std::move(statement));
|
||||
auto relative = std::filesystem::path{source};
|
||||
if (source.empty() || source.find('\0') != std::string::npos || source.find('\\') != std::string::npos ||
|
||||
relative.is_absolute() || relative.extension() != ".json" ||
|
||||
std::any_of(relative.begin(), relative.end(), [](auto const &part) { return part.empty() || part == "." || part == ".."; })) {
|
||||
return fail();
|
||||
}
|
||||
if (ensured) {
|
||||
ensured =
|
||||
co_await ensure_column(connection, "songs", "part", "ALTER TABLE songs ADD COLUMN part TEXT NOT NULL DEFAULT ''");
|
||||
auto error = std::error_code{};
|
||||
auto root = std::filesystem::weakly_canonical(assets_path, error);
|
||||
if (error) {
|
||||
return fail();
|
||||
}
|
||||
if (ensured) {
|
||||
ensured = co_await ensure_column(connection, "songs", "source_path", "ALTER TABLE songs ADD COLUMN source_path TEXT NOT NULL DEFAULT ''");
|
||||
auto path = std::filesystem::weakly_canonical(root / relative, error);
|
||||
if (error) {
|
||||
return fail();
|
||||
}
|
||||
if (ensured) {
|
||||
ensured = co_await ensure_column(connection, "songs", "key_signature", "ALTER TABLE songs ADD COLUMN key_signature TEXT NOT NULL DEFAULT ''");
|
||||
auto resolved = path.lexically_relative(root);
|
||||
if (resolved.empty() || *resolved.begin() == "..") {
|
||||
return fail();
|
||||
}
|
||||
if (ensured) {
|
||||
ensured = co_await ensure_column(connection, "songs", "catalog_managed", "ALTER TABLE songs ADD COLUMN catalog_managed INTEGER NOT NULL DEFAULT 1 CHECK(catalog_managed IN (0, 1))");
|
||||
}
|
||||
if (ensured) {
|
||||
ensured = co_await ensure_column(connection, "practice_exercises", "practice_type_id", "ALTER TABLE practice_exercises ADD COLUMN practice_type_id TEXT NOT NULL DEFAULT 'general' "
|
||||
"CHECK(length(trim(practice_type_id)) BETWEEN 1 AND 120)");
|
||||
}
|
||||
if (ensured) {
|
||||
ensured = co_await ensure_column(connection, "practice_exercises", "practice_type_title", "ALTER TABLE practice_exercises ADD COLUMN practice_type_title TEXT NOT NULL DEFAULT 'General Practice' "
|
||||
"CHECK(length(trim(practice_type_title)) BETWEEN 1 AND 200)");
|
||||
}
|
||||
if (ensured) {
|
||||
ensured = co_await ensure_column(connection, "practice_exercises", "lesson_group_id", "ALTER TABLE practice_exercises ADD COLUMN lesson_group_id TEXT NOT NULL DEFAULT 'general' "
|
||||
"CHECK(length(trim(lesson_group_id)) BETWEEN 1 AND 120)");
|
||||
}
|
||||
if (ensured) {
|
||||
ensured = co_await ensure_column(connection, "practice_exercises", "lesson_group_title", "ALTER TABLE practice_exercises ADD COLUMN lesson_group_title TEXT NOT NULL DEFAULT 'General' "
|
||||
"CHECK(length(trim(lesson_group_title)) BETWEEN 1 AND 200)");
|
||||
}
|
||||
if (ensured) {
|
||||
ensured = co_await ensure_column(connection, "practice_exercises", "source_path", "ALTER TABLE practice_exercises ADD COLUMN source_path TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
if (ensured) {
|
||||
ensured = co_await ensure_column(connection, "practice_exercises", "catalog_managed", "ALTER TABLE practice_exercises ADD COLUMN catalog_managed INTEGER NOT NULL DEFAULT 1 CHECK(catalog_managed IN "
|
||||
"(0, 1))");
|
||||
}
|
||||
auto index_statements = std::vector<std::string>{
|
||||
"CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title COLLATE NOCASE)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_songs_title_part ON songs(title COLLATE NOCASE, part COLLATE NOCASE)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_songs_catalog_managed ON songs(catalog_managed)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_practice_exercises_title ON practice_exercises(title COLLATE NOCASE)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_practice_exercises_hierarchy ON practice_exercises(practice_type_id COLLATE "
|
||||
"NOCASE, lesson_group_id COLLATE NOCASE, title COLLATE NOCASE)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_practice_exercises_catalog_managed ON practice_exercises(catalog_managed)",
|
||||
};
|
||||
for (auto table : {"songs", "practice_exercises"}) {
|
||||
for (auto column : {"measure_count", "total_pages", "authored_key_signature"}) {
|
||||
if (!ensured) {
|
||||
break;
|
||||
}
|
||||
auto type = std::string_view{column} == "authored_key_signature" ? " TEXT" : " INTEGER CHECK(" + std::string{column} + " > 0)";
|
||||
ensured = co_await ensure_column(connection, table, column, "ALTER TABLE " + std::string{table} + " ADD COLUMN " + column + type);
|
||||
}
|
||||
if (ensured) {
|
||||
ensured = co_await execute_schema_statement(connection, "CREATE TRIGGER IF NOT EXISTS invalidate_" + std::string{table} + "_metadata AFTER UPDATE OF source_json ON " + table + " WHEN NEW.source_json != OLD.source_json BEGIN UPDATE " + table + " SET measure_count = NULL, total_pages = NULL, authored_key_signature = NULL WHERE id = NEW.id; END");
|
||||
}
|
||||
index_statements.push_back("CREATE INDEX IF NOT EXISTS idx_" + std::string{table} + "_pending_metadata ON " + table + "(id) WHERE measure_count IS NULL OR total_pages IS NULL OR authored_key_signature IS NULL");
|
||||
}
|
||||
for (auto &statement : index_statements) {
|
||||
if (!ensured) {
|
||||
break;
|
||||
}
|
||||
ensured = co_await execute_schema_statement(connection, std::move(statement));
|
||||
}
|
||||
if (ensured) {
|
||||
auto committed = co_await connection.async_execute("COMMIT");
|
||||
if (!committed) {
|
||||
ensured = std::unexpected(committed.error());
|
||||
}
|
||||
} else {
|
||||
static_cast<void>(co_await connection.async_execute("ROLLBACK"));
|
||||
}
|
||||
if (!ensured) {
|
||||
co_return std::unexpected(ensured.error());
|
||||
}
|
||||
auto version = co_await connection.async_set_user_version(0);
|
||||
if (!version) {
|
||||
co_return std::unexpected(version.error());
|
||||
}
|
||||
co_return std::expected<void, modulus::error>{};
|
||||
}
|
||||
|
||||
auto path_parts(std::filesystem::path const &path) -> std::vector<std::string> {
|
||||
auto parts = std::vector<std::string>{};
|
||||
for (auto const &part : path) {
|
||||
parts.push_back(part.string());
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
auto logical_practice_exercise_id(std::string const &type_id, std::string const &lesson_id, std::string const &source_path, std::string const &fallback) -> std::string {
|
||||
auto parts = path_parts(std::filesystem::path{source_path});
|
||||
for (auto index = std::size_t{}; index + 3U < parts.size(); ++index) {
|
||||
if (parts[index] == "practice" && parts[index + 1U] == type_id && parts[index + 2U] == lesson_id) {
|
||||
return type_id + "-" + lesson_id + "-" + parts[index + 3U];
|
||||
}
|
||||
}
|
||||
auto stem = std::filesystem::path{source_path}.parent_path().filename().string();
|
||||
if (stem.empty()) {
|
||||
stem = fallback;
|
||||
}
|
||||
return type_id + "-" + lesson_id + "-" + stem;
|
||||
}
|
||||
|
||||
auto now_ms() -> std::int64_t {
|
||||
return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch())
|
||||
.count();
|
||||
}
|
||||
|
||||
auto backfill_metadata(boost::asio::any_io_executor executor, noto::connection &connection, std::string const &table) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
for (;;) {
|
||||
// Bound immediate coroutine completion chains even for very large catalogues.
|
||||
co_await boost::asio::post(executor, boost::asio::use_awaitable);
|
||||
auto row = co_await connection.async_execute("SELECT id, source_path, source_json FROM " + table + " WHERE measure_count IS NULL OR total_pages IS NULL OR authored_key_signature IS NULL LIMIT 1");
|
||||
if (!row) {
|
||||
co_return std::unexpected(row.error());
|
||||
}
|
||||
if (row->rows.empty()) {
|
||||
co_return std::expected<void, modulus::error>{};
|
||||
}
|
||||
auto const &values = row->rows.front();
|
||||
auto document = vamp_zero::parse_score_document(text_value(values[2]), text_value(values[1]));
|
||||
if (!document) {
|
||||
co_return std::unexpected(document.error());
|
||||
}
|
||||
auto params = std::vector<noto::sqlite_value>{
|
||||
noto::sqlite_value{static_cast<std::int64_t>(document->measure_count)},
|
||||
noto::sqlite_value{static_cast<std::int64_t>(document->pages.size())},
|
||||
noto::sqlite_value{document->authored_key_signature},
|
||||
noto::sqlite_value{text_value(values[0])},
|
||||
noto::sqlite_value{text_value(values[2])},
|
||||
};
|
||||
auto updated = co_await connection.async_execute("UPDATE " + table + " SET measure_count = ?1, total_pages = ?2, authored_key_signature = ?3 WHERE id = ?4 AND source_json = ?5", std::move(params));
|
||||
if (!updated) {
|
||||
co_return std::unexpected(updated.error());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto restore_imported_metadata(noto::connection &connection, std::string const &table, std::string const &id, vamp_zero::score_document const &document) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
auto params = std::vector<noto::sqlite_value>{
|
||||
noto::sqlite_value{static_cast<std::int64_t>(document.measure_count)},
|
||||
noto::sqlite_value{static_cast<std::int64_t>(document.pages.size())},
|
||||
noto::sqlite_value{document.authored_key_signature},
|
||||
noto::sqlite_value{id},
|
||||
};
|
||||
auto updated = co_await connection.async_execute("UPDATE " + table + " SET measure_count = ?1, total_pages = ?2, authored_key_signature = ?3 WHERE id = ?4 AND catalog_managed = 1 AND (measure_count IS NULL OR total_pages IS NULL OR authored_key_signature IS NULL)", std::move(params));
|
||||
if (!updated) {
|
||||
co_return std::unexpected(updated.error());
|
||||
}
|
||||
co_return std::expected<void, modulus::error>{};
|
||||
return path.string();
|
||||
}
|
||||
|
||||
auto count_value(noto::sqlite_value const &value) -> std::size_t {
|
||||
@@ -305,296 +89,44 @@ auto count_value(noto::sqlite_value const &value) -> std::size_t {
|
||||
return count == nullptr || *count <= 0 ? 0U : static_cast<std::size_t>(*count);
|
||||
}
|
||||
|
||||
auto execute_delete_stale_catalog_rows(noto::connection &connection, std::string_view table, std::vector<std::string> const &ids) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
auto sql = std::string{"DELETE FROM "};
|
||||
sql += table;
|
||||
sql += " WHERE catalog_managed = 1";
|
||||
auto params = std::vector<noto::sqlite_value>{};
|
||||
params.reserve(ids.size());
|
||||
if (!ids.empty()) {
|
||||
sql += " AND id NOT IN (";
|
||||
for (auto index = std::size_t{}; index < ids.size(); ++index) {
|
||||
if (index != 0U) {
|
||||
sql += ", ";
|
||||
}
|
||||
sql += "?";
|
||||
sql += std::to_string(index + 1U);
|
||||
params.emplace_back(ids[index]);
|
||||
}
|
||||
sql += ")";
|
||||
}
|
||||
auto deleted = co_await connection.async_execute(std::move(sql), std::move(params));
|
||||
if (!deleted) {
|
||||
co_return std::unexpected(deleted.error());
|
||||
}
|
||||
co_return std::expected<void, modulus::error>{};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
auto vamp_zero::async_ensure_database(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
if (config.path.empty()) {
|
||||
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "database path must not be empty"));
|
||||
}
|
||||
if (config.path != ":memory:") {
|
||||
auto parent = std::filesystem::path{config.path}.parent_path();
|
||||
if (!parent.empty()) {
|
||||
auto error = std::error_code{};
|
||||
std::filesystem::create_directories(parent, error);
|
||||
if (error) {
|
||||
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to create database directory: " + error.message()));
|
||||
}
|
||||
}
|
||||
auto vamp_zero::async_validate_database(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::bool_t integrity) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
auto error = std::error_code{};
|
||||
if (!std::filesystem::is_regular_file(config.path, error) || error) {
|
||||
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "catalogue is missing or unreadable: " + config.path + "; rebuild or reinstall Product Vamp"));
|
||||
}
|
||||
auto connection = noto::connection{executor, connection_config(config)};
|
||||
auto connected = co_await connection.async_connect();
|
||||
if (!connected) {
|
||||
co_return std::unexpected(connected.error());
|
||||
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "cannot open catalogue: " + config.path + "; rebuild or reinstall Product Vamp: " + connected.error().describe()));
|
||||
}
|
||||
if (config.path != ":memory:") {
|
||||
auto journal = co_await connection.async_journal_mode("WAL");
|
||||
if (!journal || *journal != "wal") {
|
||||
auto close = co_await connection.async_close();
|
||||
if (!close) {
|
||||
modulus::log_error("database close failed after WAL error: " + close.error().describe());
|
||||
}
|
||||
co_return std::unexpected(journal ? vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "SQLite did not enter WAL mode") : journal.error());
|
||||
auto application = co_await connection.async_application_id();
|
||||
auto version = co_await connection.async_user_version();
|
||||
auto status = std::expected<void, modulus::error>{};
|
||||
if (!application || !version || *application != application_id || *version != catalogue_schema_version) {
|
||||
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid or incompatible catalogue: " + config.path + "; rebuild or reinstall Product Vamp"));
|
||||
}
|
||||
if (status && integrity) {
|
||||
auto checked = co_await connection.async_execute("PRAGMA quick_check(1)");
|
||||
if (!checked || checked->rows.size() != 1U || checked->rows[0].size() != 1U || text_value(checked->rows[0][0]) != "ok") {
|
||||
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "corrupt catalogue: " + config.path + "; rebuild or reinstall Product Vamp"));
|
||||
}
|
||||
}
|
||||
auto current_application_id = co_await connection.async_application_id();
|
||||
if (!current_application_id) {
|
||||
co_return std::unexpected(current_application_id.error());
|
||||
auto closed = co_await connection.async_close();
|
||||
if (!status) {
|
||||
co_return std::unexpected(status.error());
|
||||
}
|
||||
if (*current_application_id != 0 && *current_application_id != application_id) {
|
||||
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "database application id does not belong to Product Vamp"));
|
||||
}
|
||||
if (*current_application_id == 0) {
|
||||
auto set = co_await connection.async_set_application_id(application_id);
|
||||
if (!set) {
|
||||
co_return std::unexpected(set.error());
|
||||
}
|
||||
}
|
||||
auto ensured = co_await ensure_current_schema(connection);
|
||||
if (!ensured) {
|
||||
auto close = co_await connection.async_close();
|
||||
if (!close) {
|
||||
modulus::log_error("database close failed after schema ensure error: " + close.error().describe());
|
||||
}
|
||||
co_return std::unexpected(ensured.error());
|
||||
}
|
||||
co_return co_await connection.async_close();
|
||||
co_return closed;
|
||||
}
|
||||
|
||||
auto vamp_zero::async_import_catalogs(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string assets_path) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
auto performance = vamp_zero::score_paths(assets_path, vamp_zero::score_category::PERFORMANCE);
|
||||
if (!performance) {
|
||||
co_return std::unexpected(performance.error());
|
||||
}
|
||||
auto practice = vamp_zero::score_paths(assets_path, vamp_zero::score_category::PRACTICE);
|
||||
if (!practice) {
|
||||
co_return std::unexpected(practice.error());
|
||||
}
|
||||
auto connection = noto::connection{executor, connection_config(config)};
|
||||
auto connected = co_await connection.async_connect();
|
||||
if (!connected) {
|
||||
co_return std::unexpected(connected.error());
|
||||
}
|
||||
auto begin = co_await connection.async_execute("BEGIN IMMEDIATE");
|
||||
if (!begin) {
|
||||
co_return std::unexpected(begin.error());
|
||||
}
|
||||
auto imported = std::expected<void, modulus::error>{};
|
||||
auto timestamp = now_ms();
|
||||
auto song_ids = std::vector<std::string>{};
|
||||
song_ids.reserve(performance->size());
|
||||
for (auto const &path : *performance) {
|
||||
if (!imported) {
|
||||
break;
|
||||
}
|
||||
co_await boost::asio::post(executor, boost::asio::use_awaitable);
|
||||
auto document = vamp_zero::read_score_document(path);
|
||||
if (!document) {
|
||||
imported = std::unexpected(document.error());
|
||||
break;
|
||||
}
|
||||
auto const &part = *document;
|
||||
song_ids.push_back(part.id);
|
||||
auto params = std::vector<noto::sqlite_value>{
|
||||
noto::sqlite_value{part.id},
|
||||
noto::sqlite_value{part.title},
|
||||
noto::sqlite_value{part.composer},
|
||||
noto::sqlite_value{part.source_json},
|
||||
noto::sqlite_value{timestamp},
|
||||
noto::sqlite_value{timestamp},
|
||||
noto::sqlite_value{part.part},
|
||||
noto::sqlite_value{part.source_path},
|
||||
noto::sqlite_value{part.key_signature},
|
||||
noto::sqlite_value{static_cast<std::int64_t>(part.measure_count)},
|
||||
noto::sqlite_value{static_cast<std::int64_t>(part.pages.size())},
|
||||
noto::sqlite_value{part.authored_key_signature},
|
||||
};
|
||||
auto inserted = co_await connection.async_execute(
|
||||
R"sql(
|
||||
INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, key_signature, catalog_managed, measure_count, total_pages, authored_key_signature)
|
||||
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1, ?10, ?11, ?12)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
composer = excluded.composer,
|
||||
source_json = excluded.source_json,
|
||||
part = excluded.part,
|
||||
source_path = excluded.source_path,
|
||||
key_signature = excluded.key_signature,
|
||||
catalog_managed = 1,
|
||||
measure_count = excluded.measure_count,
|
||||
total_pages = excluded.total_pages,
|
||||
authored_key_signature = excluded.authored_key_signature,
|
||||
updated_at_ms = CASE
|
||||
WHEN songs.title != excluded.title
|
||||
OR songs.composer != excluded.composer
|
||||
OR songs.source_json != excluded.source_json
|
||||
OR songs.part != excluded.part
|
||||
OR songs.source_path != excluded.source_path
|
||||
OR songs.key_signature != excluded.key_signature
|
||||
THEN excluded.updated_at_ms
|
||||
ELSE songs.updated_at_ms
|
||||
END
|
||||
WHERE songs.catalog_managed = 1
|
||||
)sql",
|
||||
std::move(params));
|
||||
if (!inserted) {
|
||||
imported = std::unexpected(inserted.error());
|
||||
} else {
|
||||
imported = co_await restore_imported_metadata(connection, "songs", part.id, *document);
|
||||
}
|
||||
}
|
||||
auto practice_ids = std::vector<std::string>{};
|
||||
auto authored_keys = std::map<std::string, std::string>{};
|
||||
for (auto const &path : *practice) {
|
||||
if (!imported) {
|
||||
break;
|
||||
}
|
||||
co_await boost::asio::post(executor, boost::asio::use_awaitable);
|
||||
auto document = vamp_zero::read_score_document(path);
|
||||
if (!document) {
|
||||
imported = std::unexpected(document.error());
|
||||
break;
|
||||
}
|
||||
auto metadata = vamp_zero::practice_metadata(*document, assets_path);
|
||||
if (!metadata) {
|
||||
imported = std::unexpected(metadata.error());
|
||||
break;
|
||||
}
|
||||
auto const &exercise = *metadata;
|
||||
auto [origin, added] = authored_keys.emplace(exercise.id, exercise.authored_key_signature);
|
||||
if (!added && origin->second != exercise.authored_key_signature) {
|
||||
imported = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "practice variants disagree on authored key: " + exercise.id));
|
||||
break;
|
||||
}
|
||||
for (auto const &variant : exercise.variants) {
|
||||
practice_ids.push_back(variant.id);
|
||||
auto params = std::vector<noto::sqlite_value>{
|
||||
noto::sqlite_value{variant.id},
|
||||
noto::sqlite_value{exercise.practice_type_id},
|
||||
noto::sqlite_value{exercise.practice_type_title},
|
||||
noto::sqlite_value{exercise.lesson_group_id},
|
||||
noto::sqlite_value{exercise.lesson_group_title},
|
||||
noto::sqlite_value{exercise.title},
|
||||
noto::sqlite_value{exercise.composer},
|
||||
noto::sqlite_value{variant.key_signature},
|
||||
noto::sqlite_value{document->source_json},
|
||||
noto::sqlite_value{variant.source_path},
|
||||
noto::sqlite_value{timestamp},
|
||||
noto::sqlite_value{timestamp},
|
||||
noto::sqlite_value{static_cast<std::int64_t>(variant.measure_count)},
|
||||
noto::sqlite_value{static_cast<std::int64_t>(variant.total_pages)},
|
||||
noto::sqlite_value{exercise.authored_key_signature},
|
||||
};
|
||||
auto inserted = co_await connection.async_execute(
|
||||
R"sql(
|
||||
INSERT INTO practice_exercises(id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, title, composer, key_signature, source_json, source_path, created_at_ms, updated_at_ms, catalog_managed, measure_count, total_pages, authored_key_signature)
|
||||
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 1, ?13, ?14, ?15)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
practice_type_id = excluded.practice_type_id,
|
||||
practice_type_title = excluded.practice_type_title,
|
||||
lesson_group_id = excluded.lesson_group_id,
|
||||
lesson_group_title = excluded.lesson_group_title,
|
||||
title = excluded.title,
|
||||
composer = excluded.composer,
|
||||
key_signature = excluded.key_signature,
|
||||
source_json = excluded.source_json,
|
||||
source_path = excluded.source_path,
|
||||
catalog_managed = 1,
|
||||
measure_count = excluded.measure_count,
|
||||
total_pages = excluded.total_pages,
|
||||
authored_key_signature = excluded.authored_key_signature,
|
||||
updated_at_ms = CASE
|
||||
WHEN practice_exercises.practice_type_id != excluded.practice_type_id
|
||||
OR practice_exercises.practice_type_title != excluded.practice_type_title
|
||||
OR practice_exercises.lesson_group_id != excluded.lesson_group_id
|
||||
OR practice_exercises.lesson_group_title != excluded.lesson_group_title
|
||||
OR practice_exercises.title != excluded.title
|
||||
OR practice_exercises.composer != excluded.composer
|
||||
OR practice_exercises.key_signature != excluded.key_signature
|
||||
OR practice_exercises.source_json != excluded.source_json
|
||||
OR practice_exercises.source_path != excluded.source_path
|
||||
THEN excluded.updated_at_ms
|
||||
ELSE practice_exercises.updated_at_ms
|
||||
END
|
||||
WHERE practice_exercises.catalog_managed = 1
|
||||
)sql",
|
||||
std::move(params));
|
||||
if (!inserted) {
|
||||
imported = std::unexpected(inserted.error());
|
||||
break;
|
||||
}
|
||||
imported = co_await restore_imported_metadata(connection, "practice_exercises", variant.id, *document);
|
||||
if (!imported) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (imported) {
|
||||
auto deleted = co_await execute_delete_stale_catalog_rows(connection, "songs", song_ids);
|
||||
if (!deleted) {
|
||||
imported = std::unexpected(deleted.error());
|
||||
}
|
||||
}
|
||||
if (imported) {
|
||||
auto deleted = co_await execute_delete_stale_catalog_rows(connection, "practice_exercises", practice_ids);
|
||||
if (!deleted) {
|
||||
imported = std::unexpected(deleted.error());
|
||||
}
|
||||
}
|
||||
if (imported) {
|
||||
auto committed = co_await connection.async_execute("COMMIT");
|
||||
if (!committed) {
|
||||
imported = std::unexpected(committed.error());
|
||||
}
|
||||
} else {
|
||||
static_cast<void>(co_await connection.async_execute("ROLLBACK"));
|
||||
}
|
||||
if (!imported) {
|
||||
auto close = co_await connection.async_close();
|
||||
if (!close) {
|
||||
modulus::log_error("database close failed after import error: " + close.error().describe());
|
||||
}
|
||||
co_return std::unexpected(imported.error());
|
||||
}
|
||||
co_return co_await connection.async_close();
|
||||
}
|
||||
|
||||
auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor executor, vamp_zero::database_config config)
|
||||
auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string assets_path)
|
||||
-> boost::asio::awaitable<std::expected<vamp_zero::library_state, modulus::error>> {
|
||||
auto connection = noto::connection{executor, connection_config(config)};
|
||||
auto connected = co_await connection.async_connect();
|
||||
if (!connected) {
|
||||
co_return std::unexpected(connected.error());
|
||||
}
|
||||
auto filled = co_await backfill_metadata(executor, connection, "songs");
|
||||
if (!filled) {
|
||||
static_cast<void>(co_await connection.async_close());
|
||||
co_return std::unexpected(filled.error());
|
||||
}
|
||||
auto selected =
|
||||
co_await connection.async_prepare("SELECT id, title, composer, part, source_path, measure_count, key_signature, total_pages FROM "
|
||||
"songs ORDER BY title COLLATE NOCASE, part COLLATE NOCASE");
|
||||
@@ -621,13 +153,18 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec
|
||||
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid performance metadata"));
|
||||
break;
|
||||
}
|
||||
auto source = resolve_source_path(assets_path, text_value(row[4]));
|
||||
if (!source) {
|
||||
status = std::unexpected(source.error());
|
||||
break;
|
||||
}
|
||||
parts.push_back({
|
||||
.id = text_value(row[0]),
|
||||
.title = text_value(row[1]),
|
||||
.composer = text_value(row[2]),
|
||||
.key_signature = text_value(row[6]),
|
||||
.part = text_value(row[3]),
|
||||
.source_path = text_value(row[4]),
|
||||
.source_path = std::move(*source),
|
||||
.measure_count = count_value(row[5]),
|
||||
.total_pages = count_value(row[7]),
|
||||
});
|
||||
@@ -639,20 +176,15 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec
|
||||
co_return vamp_zero::group_library_parts(std::move(parts));
|
||||
}
|
||||
|
||||
auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executor, vamp_zero::database_config config)
|
||||
auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string assets_path)
|
||||
-> boost::asio::awaitable<std::expected<vamp_zero::practice_library, modulus::error>> {
|
||||
auto connection = noto::connection{executor, connection_config(config)};
|
||||
auto connected = co_await connection.async_connect();
|
||||
if (!connected) {
|
||||
co_return std::unexpected(connected.error());
|
||||
}
|
||||
auto filled = co_await backfill_metadata(executor, connection, "practice_exercises");
|
||||
if (!filled) {
|
||||
static_cast<void>(co_await connection.async_close());
|
||||
co_return std::unexpected(filled.error());
|
||||
}
|
||||
auto selected = co_await connection.async_prepare("SELECT id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, title, composer, "
|
||||
"key_signature, source_path, measure_count, total_pages, authored_key_signature FROM practice_exercises ORDER BY practice_type_id COLLATE NOCASE, "
|
||||
"key_signature, source_path, measure_count, total_pages, authored_key_signature, exercise_id FROM practice_exercises ORDER BY practice_type_id COLLATE NOCASE, "
|
||||
"lesson_group_id COLLATE NOCASE, source_path COLLATE NOCASE, title COLLATE NOCASE, id COLLATE NOCASE");
|
||||
if (!selected) {
|
||||
auto close = co_await connection.async_close();
|
||||
@@ -673,13 +205,18 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
|
||||
break;
|
||||
}
|
||||
auto const &row = **next;
|
||||
if (row.size() != 12U || count_value(row[9]) == 0U || count_value(row[10]) == 0U) {
|
||||
if (row.size() != 13U || count_value(row[9]) == 0U || count_value(row[10]) == 0U || text_value(row[12]).empty()) {
|
||||
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid practice metadata"));
|
||||
break;
|
||||
}
|
||||
auto type_id = text_value(row[1]);
|
||||
auto lesson_id = text_value(row[3]);
|
||||
auto source_path = text_value(row[8]);
|
||||
auto source = resolve_source_path(assets_path, text_value(row[8]));
|
||||
if (!source) {
|
||||
status = std::unexpected(source.error());
|
||||
break;
|
||||
}
|
||||
auto source_path = std::move(*source);
|
||||
auto key_signature = text_value(row[7]).empty() ? std::string{"C"} : text_value(row[7]);
|
||||
auto variant = vamp_zero::practice_exercise::score_variant{
|
||||
.id = text_value(row[0]),
|
||||
@@ -689,7 +226,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
|
||||
.total_pages = count_value(row[10]),
|
||||
};
|
||||
exercises.push_back({
|
||||
.id = logical_practice_exercise_id(type_id, lesson_id, source_path, text_value(row[5])),
|
||||
.id = text_value(row[12]),
|
||||
.practice_type_id = std::move(type_id),
|
||||
.practice_type_title = text_value(row[2]),
|
||||
.lesson_group_id = std::move(lesson_id),
|
||||
@@ -707,7 +244,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
|
||||
co_return vamp_zero::group_practice_exercises(std::move(exercises));
|
||||
}
|
||||
|
||||
auto vamp_zero::async_load_score(boost::asio::any_io_executor executor, vamp_zero::database_config config, vamp_zero::score_reference reference) -> boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>> {
|
||||
auto vamp_zero::async_load_score(boost::asio::any_io_executor executor, vamp_zero::database_config config, vamp_zero::score_reference reference, std::string assets_path) -> boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>> {
|
||||
// Unwind inline database completions between sequential loads of many scores.
|
||||
co_await boost::asio::post(executor, boost::asio::use_awaitable);
|
||||
auto connection = noto::connection{executor, connection_config(config)};
|
||||
@@ -731,7 +268,8 @@ auto vamp_zero::async_load_score(boost::asio::any_io_executor executor, vamp_zer
|
||||
if (!row) {
|
||||
result = std::unexpected(row.error());
|
||||
} else if (*row) {
|
||||
result = vamp_zero::parse_score_document(text_value((**row)[0]), text_value((**row)[1]));
|
||||
auto source = resolve_source_path(assets_path, text_value((**row)[1]));
|
||||
result = source ? vamp_zero::parse_score_document(text_value((**row)[0]), std::move(*source)) : std::expected<vamp_zero::score_document, modulus::error>{std::unexpected(source.error())};
|
||||
if (result) {
|
||||
result->id = reference.id;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
#include <algorithm>
|
||||
#include <expected>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <system_error>
|
||||
#include <tuple>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
@@ -23,22 +19,6 @@ auto invalid(std::string message) -> modulus::error {
|
||||
return vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, std::move(message));
|
||||
}
|
||||
|
||||
auto human_label(std::string value) -> std::string {
|
||||
auto capitalize_next = std::bool_t{true};
|
||||
for (auto &ch : value) {
|
||||
if (ch == '-' || ch == '_') {
|
||||
ch = ' ';
|
||||
capitalize_next = true;
|
||||
continue;
|
||||
}
|
||||
if (capitalize_next && ch >= 'a' && ch <= 'z') {
|
||||
ch = static_cast<std::char_t>(ch - ('a' - 'A'));
|
||||
}
|
||||
capitalize_next = false;
|
||||
}
|
||||
return value.empty() ? "Score" : value;
|
||||
}
|
||||
|
||||
auto key_rank(std::string const &key) -> std::size_t {
|
||||
auto const &keys = vamp_zero::practice_key_names();
|
||||
auto tonic = key.substr(0, key.find(' '));
|
||||
@@ -51,118 +31,6 @@ auto vamp_zero::parse_score_document(std::string_view json, std::string source_p
|
||||
return vamp_zero::parse_notation_document(json, std::move(source_path));
|
||||
}
|
||||
|
||||
auto vamp_zero::read_score_document(std::filesystem::path const &path) -> std::expected<vamp_zero::score_document, modulus::error> {
|
||||
auto input = std::ifstream{path, std::ios::binary};
|
||||
if (!input) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::NOT_FOUND, "failed to open score JSON: " + path.string()));
|
||||
}
|
||||
auto text = std::string{std::istreambuf_iterator<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
|
||||
if (input.bad()) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to read score JSON: " + path.string()));
|
||||
}
|
||||
return vamp_zero::parse_score_document(text, path.string());
|
||||
}
|
||||
|
||||
auto vamp_zero::score_paths(std::string const &assets_path, vamp_zero::score_category category) -> std::expected<std::vector<std::filesystem::path>, modulus::error> {
|
||||
auto practice = category == vamp_zero::score_category::PRACTICE;
|
||||
auto root = std::filesystem::path{assets_path} / (practice ? "practice" : "songs");
|
||||
auto error = std::error_code{};
|
||||
auto exists = std::filesystem::exists(root, error);
|
||||
if (error) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, root.string() + ": " + error.message()));
|
||||
}
|
||||
if (!exists) {
|
||||
if (practice) {
|
||||
return std::vector<std::filesystem::path>{};
|
||||
}
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::NOT_FOUND, "score asset directory not found: " + root.string()));
|
||||
}
|
||||
auto paths = std::vector<std::filesystem::path>{};
|
||||
auto entries = std::filesystem::recursive_directory_iterator{root, error};
|
||||
auto end = std::filesystem::recursive_directory_iterator{};
|
||||
while (!error && entries != end) {
|
||||
auto const &entry = *entries;
|
||||
if (entry.is_regular_file(error) && entry.path().extension() == ".json") {
|
||||
auto relative = entry.path().lexically_relative(root);
|
||||
if (std::distance(relative.begin(), relative.end()) != (practice ? 4 : 1)) {
|
||||
return std::unexpected(invalid("unexpected score asset path: " + entry.path().string()));
|
||||
}
|
||||
paths.push_back(entry.path());
|
||||
}
|
||||
if (!error) {
|
||||
entries.increment(error);
|
||||
}
|
||||
}
|
||||
if (error) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to scan score assets: " + error.message()));
|
||||
}
|
||||
std::sort(paths.begin(), paths.end());
|
||||
if (practice) {
|
||||
auto exercises = std::map<std::string, std::filesystem::path>{};
|
||||
auto variants = std::map<std::string, std::filesystem::path>{};
|
||||
for (auto const &path : paths) {
|
||||
auto relative = path.lexically_relative(root);
|
||||
auto exercise_path = relative.parent_path();
|
||||
auto id = std::string{};
|
||||
for (auto const &part : exercise_path) {
|
||||
id += (id.empty() ? "" : "-") + part.string();
|
||||
}
|
||||
auto [exercise, added] = exercises.emplace(id, relative);
|
||||
if (!added && exercise->second.parent_path() != exercise_path) {
|
||||
return std::unexpected(invalid("practice exercise ID collision '" + id + "': " + (root / exercise->second).string() + " and " + path.string()));
|
||||
}
|
||||
auto variant_id = id + "-" + path.stem().string();
|
||||
auto [variant, unique] = variants.emplace(variant_id, relative);
|
||||
if (!unique) {
|
||||
return std::unexpected(invalid("practice variant ID collision '" + variant_id + "': " + (root / variant->second).string() + " and " + path.string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
auto vamp_zero::performance_metadata(vamp_zero::score_document const &document) -> vamp_zero::library_part {
|
||||
return {
|
||||
.id = document.id,
|
||||
.title = document.title,
|
||||
.composer = document.composer,
|
||||
.key_signature = document.key_signature,
|
||||
.part = document.part,
|
||||
.source_path = document.source_path,
|
||||
.measure_count = document.measure_count,
|
||||
.total_pages = vamp_zero::score_page_count(document),
|
||||
};
|
||||
}
|
||||
|
||||
auto vamp_zero::practice_metadata(vamp_zero::score_document const &document, std::string const &assets_path) -> std::expected<vamp_zero::practice_exercise, modulus::error> {
|
||||
auto relative = std::filesystem::path{document.source_path}.lexically_relative(std::filesystem::path{assets_path} / "practice");
|
||||
auto parts = std::vector<std::string>{};
|
||||
for (auto const &part : relative) {
|
||||
parts.push_back(part.string());
|
||||
}
|
||||
if (parts.size() != 4U) {
|
||||
return std::unexpected(invalid("practice JSON must be stored as practice/<type>/<lesson_group>/<exercise>/<key>.json: " + document.source_path));
|
||||
}
|
||||
auto id = parts[0] + "-" + parts[1] + "-" + parts[2];
|
||||
return vamp_zero::practice_exercise{
|
||||
.id = id,
|
||||
.practice_type_id = parts[0],
|
||||
.practice_type_title = human_label(parts[0]) + " Practice",
|
||||
.lesson_group_id = parts[1],
|
||||
.lesson_group_title = human_label(parts[1]),
|
||||
.title = document.title,
|
||||
.composer = document.composer,
|
||||
.authored_key_signature = document.authored_key_signature,
|
||||
.variants = {{
|
||||
.id = id + "-" + std::filesystem::path{parts[3]}.stem().string(),
|
||||
.key_signature = document.key_signature,
|
||||
.source_path = document.source_path,
|
||||
.measure_count = document.measure_count,
|
||||
.total_pages = vamp_zero::score_page_count(document),
|
||||
}},
|
||||
};
|
||||
}
|
||||
|
||||
auto vamp_zero::group_library_parts(std::vector<vamp_zero::library_part> parts) -> vamp_zero::library_state {
|
||||
std::sort(parts.begin(), parts.end(), [](auto const &left, auto const &right) {
|
||||
return std::tie(left.title, left.composer, left.part, left.id) < std::tie(right.title, right.composer, right.part, right.id);
|
||||
@@ -210,39 +78,3 @@ auto vamp_zero::group_practice_exercises(std::vector<vamp_zero::practice_exercis
|
||||
}
|
||||
return vamp_zero::practice_library{.exercises = std::move(merged), .types = std::move(types)};
|
||||
}
|
||||
|
||||
auto vamp_zero::load_performance_library(std::string const &assets_path) -> std::expected<vamp_zero::library_state, modulus::error> {
|
||||
auto paths = vamp_zero::score_paths(assets_path, vamp_zero::score_category::PERFORMANCE);
|
||||
if (!paths) {
|
||||
return std::unexpected(paths.error());
|
||||
}
|
||||
auto parts = std::vector<vamp_zero::library_part>{};
|
||||
for (auto const &path : *paths) {
|
||||
auto document = vamp_zero::read_score_document(path);
|
||||
if (!document) {
|
||||
return std::unexpected(document.error());
|
||||
}
|
||||
parts.push_back(vamp_zero::performance_metadata(*document));
|
||||
}
|
||||
return vamp_zero::group_library_parts(std::move(parts));
|
||||
}
|
||||
|
||||
auto vamp_zero::load_practice_library(std::string const &assets_path) -> std::expected<vamp_zero::practice_library, modulus::error> {
|
||||
auto paths = vamp_zero::score_paths(assets_path, vamp_zero::score_category::PRACTICE);
|
||||
if (!paths) {
|
||||
return std::unexpected(paths.error());
|
||||
}
|
||||
auto exercises = std::vector<vamp_zero::practice_exercise>{};
|
||||
for (auto const &path : *paths) {
|
||||
auto document = vamp_zero::read_score_document(path);
|
||||
if (!document) {
|
||||
return std::unexpected(document.error());
|
||||
}
|
||||
auto exercise = vamp_zero::practice_metadata(*document, assets_path);
|
||||
if (!exercise) {
|
||||
return std::unexpected(exercise.error());
|
||||
}
|
||||
exercises.push_back(std::move(*exercise));
|
||||
}
|
||||
return vamp_zero::group_practice_exercises(std::move(exercises));
|
||||
}
|
||||
|
||||
@@ -557,10 +557,10 @@ private:
|
||||
|
||||
}
|
||||
|
||||
vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::function<void(vamp_protocol::page_command)> command_handler)
|
||||
vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::string assets_path, std::function<void(vamp_protocol::page_command)> command_handler)
|
||||
: application_{std::make_shared<luma::application>(std::move(config))},
|
||||
runtime_{std::make_shared<vamp_zero::ui_runtime>(executor, std::move(command_handler))},
|
||||
score_loader_{std::make_shared<vamp_zero::score_loader>(executor, [executor, database = std::move(database)](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference)); }, [weak = std::weak_ptr{application_}] {
|
||||
score_loader_{std::make_shared<vamp_zero::score_loader>(executor, [executor, database = std::move(database), assets = std::move(assets_path)](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference), assets); }, [weak = std::weak_ptr{application_}] {
|
||||
if (auto application = weak.lock()) {
|
||||
static_cast<void>(application->request_layout());
|
||||
} })} {}
|
||||
|
||||
Reference in New Issue
Block a user