212 lines
7.2 KiB
C++
212 lines
7.2 KiB
C++
/*
|
|||
|
|
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
|