/* streamer-tools OBS Camera Plugin - streamer-tools API client Copyright (C) 2026 CyberCoveLLC 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 */ #include "stplugin/api_client.h" #include "stplugin/json.h" #include 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 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