279 lines
12 KiB
C++
279 lines
12 KiB
C++
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cstdint>
|
|
#include <expected>
|
|
#include <filesystem>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <utility>
|
|
#include <variant>
|
|
#include <vector>
|
|
|
|
#include <boost/asio/any_io_executor.hpp>
|
|
#include <boost/asio/awaitable.hpp>
|
|
#include <boost/asio/post.hpp>
|
|
#include <boost/asio/use_awaitable.hpp>
|
|
|
|
#include <noto.hpp>
|
|
|
|
#include <vamp_zero/database.hpp>
|
|
#include <vamp_zero/errors.hpp>
|
|
#include <vamp_zero/library.hpp>
|
|
#include <vamp_zero/score.hpp>
|
|
|
|
|
|
namespace {
|
|
|
|
constexpr auto application_id = std::int64_t{0x56414D50};
|
|
constexpr auto catalogue_schema_version = std::int64_t{2};
|
|
|
|
auto connection_config(vamp_zero::database_config const &config) -> noto::connection_config {
|
|
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.pragmas = {{.name = "query_only", .value = true}};
|
|
return result;
|
|
}
|
|
|
|
auto text_value(noto::sqlite_value const &value) -> std::string {
|
|
auto text = std::get_if<std::string>(&value.value);
|
|
return text == nullptr ? std::string{} : *text;
|
|
}
|
|
|
|
auto close_catalogue_query(noto::connection &connection, noto::statement &statement, std::expected<void, modulus::error> status) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
|
auto finalized = co_await statement.async_finalize();
|
|
if (!finalized) {
|
|
modulus::log_error("catalogue query finalization failed: " + finalized.error().describe());
|
|
if (status) {
|
|
status = std::unexpected(finalized.error());
|
|
}
|
|
}
|
|
auto closed = co_await connection.async_close();
|
|
if (!closed) {
|
|
modulus::log_error("catalogue connection close failed: " + closed.error().describe());
|
|
if (status) {
|
|
status = std::unexpected(closed.error());
|
|
}
|
|
}
|
|
co_return status;
|
|
}
|
|
|
|
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"));
|
|
};
|
|
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();
|
|
}
|
|
auto error = std::error_code{};
|
|
auto root = std::filesystem::weakly_canonical(assets_path, error);
|
|
if (error) {
|
|
return fail();
|
|
}
|
|
auto path = std::filesystem::weakly_canonical(root / relative, error);
|
|
if (error) {
|
|
return fail();
|
|
}
|
|
auto resolved = path.lexically_relative(root);
|
|
if (resolved.empty() || *resolved.begin() == "..") {
|
|
return fail();
|
|
}
|
|
return path.string();
|
|
}
|
|
|
|
auto count_value(noto::sqlite_value const &value) -> std::size_t {
|
|
auto count = std::get_if<std::int64_t>(&value.value);
|
|
return count == nullptr || *count <= 0 ? 0U : static_cast<std::size_t>(*count);
|
|
}
|
|
|
|
}
|
|
|
|
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(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "cannot open catalogue: " + config.path + "; rebuild or reinstall Product Vamp: " + connected.error().describe()));
|
|
}
|
|
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 closed = co_await connection.async_close();
|
|
if (!status) {
|
|
co_return std::unexpected(status.error());
|
|
}
|
|
co_return closed;
|
|
}
|
|
|
|
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 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");
|
|
if (!selected) {
|
|
auto close = co_await connection.async_close();
|
|
if (!close) {
|
|
modulus::log_error("database close failed after load error: " + close.error().describe());
|
|
}
|
|
co_return std::unexpected(selected.error());
|
|
}
|
|
auto parts = std::vector<vamp_zero::library_part>{};
|
|
auto status = std::expected<void, modulus::error>{};
|
|
for (;;) {
|
|
auto next = co_await selected->async_step();
|
|
if (!next) {
|
|
status = std::unexpected(next.error());
|
|
break;
|
|
}
|
|
if (!*next) {
|
|
break;
|
|
}
|
|
auto const &row = **next;
|
|
if (row.size() != 8U || count_value(row[5]) == 0U || count_value(row[7]) == 0U) {
|
|
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 = std::move(*source),
|
|
.measure_count = count_value(row[5]),
|
|
.total_pages = count_value(row[7]),
|
|
});
|
|
}
|
|
auto closed = co_await close_catalogue_query(connection, *selected, std::move(status));
|
|
if (!closed) {
|
|
co_return std::unexpected(closed.error());
|
|
}
|
|
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, 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 selected = co_await connection.async_prepare("SELECT id, title, composer, key_signature, source_path, measure_count, total_pages, authored_key_signature, exercise_id "
|
|
"FROM practice_exercises ORDER BY exercise_id, id");
|
|
if (!selected) {
|
|
auto close = co_await connection.async_close();
|
|
if (!close) {
|
|
modulus::log_error("database close failed after practice load error: " + close.error().describe());
|
|
}
|
|
co_return std::unexpected(selected.error());
|
|
}
|
|
auto exercises = std::vector<vamp_zero::practice_exercise>{};
|
|
auto status = std::expected<void, modulus::error>{};
|
|
for (;;) {
|
|
auto next = co_await selected->async_step();
|
|
if (!next) {
|
|
status = std::unexpected(next.error());
|
|
break;
|
|
}
|
|
if (!*next) {
|
|
break;
|
|
}
|
|
auto const &row = **next;
|
|
if (row.size() != 9U || count_value(row[5]) == 0U || count_value(row[6]) == 0U || text_value(row[8]).empty() || text_value(row[3]).empty()) {
|
|
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid practice metadata"));
|
|
break;
|
|
}
|
|
auto source = resolve_source_path(assets_path, text_value(row[4]));
|
|
if (!source) {
|
|
status = std::unexpected(source.error());
|
|
break;
|
|
}
|
|
auto variant = vamp_zero::practice_exercise::score_variant{
|
|
.id = text_value(row[0]),
|
|
.key_signature = text_value(row[3]),
|
|
.source_path = std::move(*source),
|
|
.measure_count = count_value(row[5]),
|
|
.total_pages = count_value(row[6]),
|
|
};
|
|
exercises.push_back({
|
|
.id = text_value(row[8]),
|
|
.title = text_value(row[1]),
|
|
.composer = text_value(row[2]),
|
|
.authored_key_signature = text_value(row[7]),
|
|
.variants = {std::move(variant)},
|
|
});
|
|
}
|
|
auto closed = co_await close_catalogue_query(connection, *selected, std::move(status));
|
|
if (!closed) {
|
|
co_return std::unexpected(closed.error());
|
|
}
|
|
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, 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)};
|
|
auto connected = co_await connection.async_connect();
|
|
if (!connected) {
|
|
co_return std::unexpected(connected.error());
|
|
}
|
|
auto table = reference.category == vamp_zero::score_category::PERFORMANCE ? "songs" : "practice_exercises";
|
|
auto selected = co_await connection.async_prepare("SELECT source_json, source_path FROM " + std::string{table} + " WHERE id = ?1");
|
|
if (!selected) {
|
|
static_cast<void>(co_await connection.async_close());
|
|
co_return std::unexpected(selected.error());
|
|
}
|
|
auto result = std::expected<vamp_zero::score_document, modulus::error>{std::unexpected(vamp_zero::make_error(vamp_zero::error_code::NOT_FOUND, "score not found: " + reference.id))};
|
|
auto params = std::vector<noto::sqlite_value>{noto::sqlite_value{reference.id}};
|
|
auto bound = co_await selected->async_reset(std::move(params));
|
|
if (!bound) {
|
|
result = std::unexpected(bound.error());
|
|
} else {
|
|
auto row = co_await selected->async_step();
|
|
if (!row) {
|
|
result = std::unexpected(row.error());
|
|
} else if (*row) {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
auto status = std::expected<void, modulus::error>{};
|
|
if (!result) {
|
|
status = std::unexpected(result.error());
|
|
}
|
|
auto closed = co_await close_catalogue_query(connection, *selected, std::move(status));
|
|
if (!closed) {
|
|
co_return std::unexpected(closed.error());
|
|
}
|
|
co_return result;
|
|
}
|