Files
obs-streamer-tools-plugin/core/src/api_client.cpp
T

212 lines
7.2 KiB
C++
Raw Normal View History

/*
streamer-tools OBS Camera Plugin - streamer-tools API client
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>
*/
#include "stplugin/api_client.h"
#include "stplugin/json.h"
#include <utility>
namespace stplugin {
namespace {
std::string trim(const std::string &s)
{
std::size_t begin = 0;
std::size_t end = s.size();
auto is_space = [](char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; };
while (begin < end && is_space(s[begin]))
++begin;
while (end > begin && is_space(s[end - 1]))
--end;
return s.substr(begin, end - begin);
}
/// Map a completed HTTP response onto the shared status codes. Returns
/// ApiStatus::Ok when the caller should go on to parse the body.
ApiStatus classify(const HttpResponse &response, std::string &message)
{
if (!response.ok()) {
message = response.network_error;
return ApiStatus::NetworkError;
}
if (response.status >= 200 && response.status < 300)
return ApiStatus::Ok;
if (response.status == 404) {
message = "unknown room slug, or the read key is wrong or has been rotated";
return ApiStatus::NotFound;
}
if (response.status == 503) {
message = "the streamer-tools server has no LiveKit credentials configured";
return ApiStatus::Unavailable;
}
message = "HTTP " + std::to_string(response.status);
return ApiStatus::HttpError;
}
std::string buildUrl(const ConnectionConfig &config, const char *suffix)
{
return ApiClient::normalizeServerUrl(config.server_url) + "/api/obs/" +
urlEncode(trim(config.room_slug)) + suffix + "?key=" + urlEncode(trim(config.read_key));
}
} // namespace
const char *describeApiStatus(ApiStatus status)
{
switch (status) {
case ApiStatus::Ok: return "ok";
case ApiStatus::InvalidConfig: return "server URL, room slug and read key are all required";
case ApiStatus::NetworkError: return "could not reach the streamer-tools server";
case ApiStatus::NotFound: return "room not found, or the read key is wrong";
case ApiStatus::Unavailable: return "server has no LiveKit configured";
case ApiStatus::HttpError: return "unexpected response from the streamer-tools server";
case ApiStatus::MalformedResponse: return "unreadable response from the streamer-tools server";
}
return "unknown error";
}
ApiClient::ApiClient(std::shared_ptr<HttpClient> http) : http_(std::move(http)) {}
std::string ApiClient::normalizeServerUrl(const std::string &raw)
{
std::string url = trim(raw);
if (url.empty())
return url;
// A bare "streamers.example.com" is what an operator will paste half the
// time. Defaulting to https (never http) keeps the read key off the wire
// in the clear.
const bool has_scheme = url.compare(0, 7, "http://") == 0 || url.compare(0, 8, "https://") == 0;
if (!has_scheme)
url = "https://" + url;
while (!url.empty() && url.back() == '/')
url.pop_back();
// "https://" with nothing after it is not a server.
if (url == "https:/" || url == "https:" || url == "http:/" || url == "http:" ||
url == "https://" || url == "http://")
return std::string();
return url;
}
std::string ApiClient::redactedUrl(const std::string &url)
{
const std::size_t at = url.find("key=");
if (at == std::string::npos)
return url;
const std::size_t value = at + 4;
std::size_t end = url.find('&', value);
if (end == std::string::npos)
end = url.size();
return url.substr(0, value) + "***" + url.substr(end);
}
SlotsResult ApiClient::fetchSlots(const ConnectionConfig &config) const
{
SlotsResult result;
if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) {
result.status = ApiStatus::InvalidConfig;
result.message = describeApiStatus(ApiStatus::InvalidConfig);
return result;
}
HttpRequest request;
request.method = "GET";
request.url = buildUrl(config, "/slots");
const HttpResponse response = http_->send(request);
const ApiStatus status = classify(response, result.message);
if (status != ApiStatus::Ok) {
result.status = status;
return result;
}
const json::Value root = json::parse(response.body);
const json::Value &slots = root["slots"];
if (!root.isObject() || !slots.isArray()) {
result.status = ApiStatus::MalformedResponse;
result.message = "expected a JSON object with a \"slots\" array";
return result;
}
for (const json::Value &entry : slots.elements()) {
// A slot without an identity is unusable -- it is what the session
// wrapper subscribes by -- so skip it rather than surfacing a
// dropdown row that can never connect. Anything else is best-effort:
// a missing displayName falls back to the identity exactly as the
// server itself does.
const std::string identity = entry["identity"].asString();
if (identity.empty())
continue;
SlotInfo slot;
slot.identity = identity;
slot.display_name = entry["displayName"].asString(identity);
if (slot.display_name.empty())
slot.display_name = identity;
slot.live = entry["live"].asBool(false);
result.slots.push_back(std::move(slot));
}
result.status = ApiStatus::Ok;
return result;
}
TokenResult ApiClient::requestToken(const ConnectionConfig &config) const
{
TokenResult result;
if (!config.is_valid() || normalizeServerUrl(config.server_url).empty() || !http_) {
result.status = ApiStatus::InvalidConfig;
result.message = describeApiStatus(ApiStatus::InvalidConfig);
return result;
}
HttpRequest request;
request.method = "POST";
request.url = buildUrl(config, "/token");
request.content_type = "application/json";
request.body = "{}";
const HttpResponse response = http_->send(request);
const ApiStatus status = classify(response, result.message);
if (status != ApiStatus::Ok) {
result.status = status;
return result;
}
const json::Value root = json::parse(response.body);
const std::string token = root["lkToken"].asString();
const std::string ws_url = root["wsUrl"].asString();
if (!root.isObject() || token.empty() || ws_url.empty()) {
result.status = ApiStatus::MalformedResponse;
result.message = "expected a JSON object with non-empty \"lkToken\" and \"wsUrl\"";
return result;
}
result.lk_token = token;
result.ws_url = ws_url;
result.identity = root["identity"].asString();
result.status = ApiStatus::Ok;
return result;
}
} // namespace stplugin