From 892a0665bd0c7e0c77862c3d2817e923c0c6268d Mon Sep 17 00:00:00 2001 From: Sharps <8174841+Sharps@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:50:35 +0300 Subject: [PATCH] Add OK.ru livestream support --- api/CMakeLists.txt | 11 ++++ api/src/Channel.h | 5 +- api/src/OkruClient.cpp | 79 +++++++++++++++++++++++++ api/src/OkruClient.h | 27 +++++++++ api/src/ServicePoller.cpp | 19 ++++++ api/src/ServicePoller.h | 2 + api/tests/OkruTest.cpp | 93 ++++++++++++++++++++++++++++++ src/components/ServiceSelect.jsx | 1 + src/components/StreamEmbed.jsx | 3 + src/components/StreamThumbnail.jsx | 7 ++- 10 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 api/src/OkruClient.cpp create mode 100644 api/src/OkruClient.h create mode 100644 api/tests/OkruTest.cpp diff --git a/api/CMakeLists.txt b/api/CMakeLists.txt index 2b4d0db..97f3081 100644 --- a/api/CMakeLists.txt +++ b/api/CMakeLists.txt @@ -77,6 +77,7 @@ set(SOURCE_FILES src/HTTPService.cpp src/IPRanges.cpp src/MIMETypes.cpp + src/OkruClient.cpp src/ServicePoller.cpp src/Session.cpp src/SmashcastClient.cpp @@ -151,6 +152,15 @@ add_executable(angelthump_test target_include_directories(angelthump_test PRIVATE ${TEST_LIB_HEADER}) target_link_libraries(angelthump_test PRIVATE ${TEST_LIB}) +add_executable(okru_test + tests/OkruTest.cpp + src/OkruClient.cpp + src/Curl.cpp + src/APIClient.cpp + src/Status.cpp) +target_include_directories(okru_test PRIVATE ${TEST_LIB_HEADER}) +target_link_libraries(okru_test PRIVATE ${TEST_LIB}) + add_executable(smashcast_test tests/SmashcastTest.cpp src/SmashcastClient.cpp @@ -175,5 +185,6 @@ add_test(ip_ranges ip_ranges_test) add_test(curl curl_test) add_test(youtube youtube_test) add_test(angelthump angelthump_test) +add_test(okru okru_test) add_test(smashcast smashcast_test) add_test(streams streams_test) diff --git a/api/src/Channel.h b/api/src/Channel.h index 981835f..9d13c46 100644 --- a/api/src/Channel.h +++ b/api/src/Channel.h @@ -17,6 +17,7 @@ constexpr folly::StringPiece kAdvancedService{"advanced"}; constexpr folly::StringPiece kAngelThumpService{"angelthump"}; constexpr folly::StringPiece kFacebookService{"facebook"}; constexpr folly::StringPiece kM3u8Service{"m3u8"}; +constexpr folly::StringPiece kOkruService{"okru"}; constexpr folly::StringPiece kSmashcastService{"smashcast"}; constexpr folly::StringPiece kTwitchService{"twitch"}; constexpr folly::StringPiece kTwitchVODService{"twitch-vod"}; @@ -24,11 +25,11 @@ constexpr folly::StringPiece kUstreamService{"ustream"}; constexpr folly::StringPiece kYouTubePlaylistService{"youtube-playlist"}; constexpr folly::StringPiece kYouTubeService{"youtube"}; -constexpr std::array kServices{ +constexpr std::array kServices{ kAdvancedService, kAngelThumpService, kFacebookService, kM3u8Service, kSmashcastService, kTwitchService, kTwitchVODService, kUstreamService, kYouTubePlaylistService, - kYouTubeService}; + kYouTubeService, kOkruService}; constexpr std::array kCaseInsensitiveServices{ kAngelThumpService, kTwitchService, kUstreamService}; diff --git a/api/src/OkruClient.cpp b/api/src/OkruClient.cpp new file mode 100644 index 0000000..bae9c62 --- /dev/null +++ b/api/src/OkruClient.cpp @@ -0,0 +1,79 @@ +#include "OkruClient.h" + +#include "Curl.h" +#include "JSON.h" + +namespace rustla2 { +namespace okru { + +rapidjson::Document ChannelResult::GetSchema() { + rapidjson::Document schema; + schema.Parse(R"json( + { + "type": "object", + "properties": { + "movie": { + "type": "object", + "properties": { + "status": {"type": "string"}, + "isLive": {"type": "boolean"}, + "title": {"type": ["string", "null"]}, + "poster": {"type": ["string", "null"]} + }, + "required": ["status", "isLive"] + } + }, + "required": ["movie"] + } + )json"); + return schema; +} + +std::string ChannelResult::GetTitle() const { + const auto& movie = GetData()["movie"]; + if (movie.HasMember("title") && movie["title"].IsString()) { + return json::StringRef(movie["title"]); + } + return ""; +} + +bool ChannelResult::GetLive() const { + const auto& movie = GetData()["movie"]; + // Paused broadcasts and recordings can also have isLive set to true. + return movie["isLive"].GetBool() && + json::StringRef(movie["status"]) == "ONLINE"; +} + +std::string ChannelResult::GetThumbnail() const { + const auto& movie = GetData()["movie"]; + if (movie.HasMember("poster") && movie["poster"].IsString()) { + return json::StringRef(movie["poster"]); + } + return ""; +} + +Status Client::GetChannelByID(const std::string& id, ChannelResult* result) { + if (id.empty() || id.find_first_not_of("0123456789") != std::string::npos) { + return Status(StatusCode::VALIDATION_ERROR, "invalid OK.ru broadcast ID"); + } + + CurlRequest req("https://ok.ru/dk?cmd=videoPlayerMetadata&mid=" + id); + // The player metadata endpoint requires POST, even with an empty body. + req.SetPostData("", 0); + req.Submit(); + + if (!req.Ok()) { + return Status(StatusCode::HTTP_ERROR, req.GetErrorMessage()); + } + if (req.GetResponseCode() != 200) { + return Status( + StatusCode::API_ERROR, "received non 200 response", + "api returned status code " + std::to_string(req.GetResponseCode())); + } + + const auto& response = req.GetResponse(); + return result->SetData(response.c_str(), response.size()); +} + +} // namespace okru +} // namespace rustla2 diff --git a/api/src/OkruClient.h b/api/src/OkruClient.h new file mode 100644 index 0000000..d49ab92 --- /dev/null +++ b/api/src/OkruClient.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include "APIClient.h" + +namespace rustla2 { +namespace okru { + +class ChannelResult : public APIResult { + public: + rapidjson::Document GetSchema() final; + + std::string GetTitle() const; + + bool GetLive() const; + + std::string GetThumbnail() const; +}; + +class Client { + public: + Status GetChannelByID(const std::string& id, ChannelResult* result); +}; + +} // namespace okru +} // namespace rustla2 diff --git a/api/src/ServicePoller.cpp b/api/src/ServicePoller.cpp index aef3d50..bce4a24 100644 --- a/api/src/ServicePoller.cpp +++ b/api/src/ServicePoller.cpp @@ -2,6 +2,7 @@ #include "AngelThumpClient.h" #include "Config.h" +#include "OkruClient.h" namespace rustla2 { @@ -30,6 +31,8 @@ void ServicePoller::Run() { status = CheckTwitchVOD(channel->GetChannel(), &state); } else if (channel->GetService() == kAngelThumpService) { status = CheckAngelThump(channel->GetChannel(), &state); + } else if (channel->GetService() == kOkruService) { + status = CheckOkru(channel->GetChannel(), &state); } else if (channel->GetService() == kYouTubeService) { status = CheckYouTube(channel->GetChannel(), &state); } else if (channel->GetService() == kM3u8Service) { @@ -74,6 +77,22 @@ const Status ServicePoller::CheckAngelThump(const std::string &name, return status; } +const Status ServicePoller::CheckOkru(const std::string &id, + ChannelState *state) { + okru::Client client; + okru::ChannelResult channel; + auto status = client.GetChannelByID(id, &channel); + + if (!status.Ok()) { + return status; + } + + state->title = channel.GetTitle(); + state->live = channel.GetLive(); + state->thumbnail = channel.GetThumbnail(); + return status; +} + const Status ServicePoller::CheckM3u8(const std::string &name, ChannelState *state) { CurlRequest req(name); diff --git a/api/src/ServicePoller.h b/api/src/ServicePoller.h index bcca6f3..4f4cceb 100644 --- a/api/src/ServicePoller.h +++ b/api/src/ServicePoller.h @@ -32,6 +32,8 @@ class ServicePoller { const Status CheckM3u8(const std::string& name, ChannelState* state); + const Status CheckOkru(const std::string& id, ChannelState* state); + const Status CheckTwitchStream(const std::string& name, ChannelState* state); const Status CheckTwitchVOD(const std::string& name, ChannelState* state); diff --git a/api/tests/OkruTest.cpp b/api/tests/OkruTest.cpp new file mode 100644 index 0000000..50ba0fd --- /dev/null +++ b/api/tests/OkruTest.cpp @@ -0,0 +1,93 @@ +#include +#include +#include + +#include "../src/OkruClient.h" + +namespace rustla2 { + +TEST(OkruTest, OnlineBroadcast) { + const std::string response = R"json({"movie": { + "status": "ONLINE", "isLive": true, "title": "Test broadcast", + "poster": "https://iv.okcdn.ru/i?r=poster&fn=external_8" + }})json"; + + okru::ChannelResult channel; + ASSERT_TRUE(channel.SetData(response.c_str(), response.size()).Ok()); + EXPECT_TRUE(channel.GetLive()); + EXPECT_EQ(channel.GetTitle(), "Test broadcast"); + EXPECT_EQ(channel.GetThumbnail(), + "https://iv.okcdn.ru/i?r=poster&fn=external_8"); +} + +TEST(OkruTest, PausedBroadcastWithPlaybackURLsIsOffline) { + const std::string response = R"json({ + "movie": {"status": "OFFLINE", "isLive": true}, + "hlsMasterPlaylistUrl": "https://example.com/live.m3u8", + "liveDashManifestUrl": "https://example.com/live.mpd" + })json"; + + okru::ChannelResult channel; + ASSERT_TRUE(channel.SetData(response.c_str(), response.size()).Ok()); + EXPECT_FALSE(channel.GetLive()); + EXPECT_TRUE(channel.GetTitle().empty()); + EXPECT_TRUE(channel.GetThumbnail().empty()); +} + +TEST(OkruTest, RecordingsAndUnknownStatusesAreNotLive) { + const std::string responses[] = { + R"({"movie":{"status":"OK","isLive":true}})", + R"({"movie":{"status":"ONLINE","isLive":false}})", + R"({"movie":{"status":"UNKNOWN","isLive":true}})"}; + for (const auto& response : responses) { + okru::ChannelResult channel; + ASSERT_TRUE(channel.SetData(response.c_str(), response.size()).Ok()); + EXPECT_FALSE(channel.GetLive()) << response; + } +} + +TEST(OkruTest, NullTitleAndPosterAreOptional) { + const std::string response = R"json({"movie": { + "status": "ONLINE", "isLive": true, "title": null, "poster": null + }})json"; + + okru::ChannelResult channel; + ASSERT_TRUE(channel.SetData(response.c_str(), response.size()).Ok()); + EXPECT_TRUE(channel.GetLive()); + EXPECT_TRUE(channel.GetTitle().empty()); + EXPECT_TRUE(channel.GetThumbnail().empty()); +} + +TEST(OkruTest, RejectsMalformedAndErrorResponses) { + const std::string responses[] = { + "Unavailable", + R"({"error":"Video not found"})", + R"({"movie":null})", + R"({"movie":{"isLive":true}})", + R"({"movie":{"status":"ONLINE"}})", + R"({"movie":{"status":true,"isLive":true}})", + R"({"movie":{"status":"ONLINE","isLive":"true"}})", + R"({"movie":{"status":"ONLINE","isLive":true,"poster":42}})"}; + for (const auto& response : responses) { + okru::ChannelResult channel; + EXPECT_FALSE(channel.SetData(response.c_str(), response.size()).Ok()) + << response; + } +} + +TEST(OkruTest, RejectsInvalidIDsBeforeRequestingMetadata) { + okru::Client client; + okru::ChannelResult channel; + EXPECT_FALSE(client.GetChannelByID("", &channel).Ok()); + EXPECT_FALSE(client.GetChannelByID("not-a-broadcast", &channel).Ok()); + EXPECT_FALSE(client.GetChannelByID("123&other=456", &channel).Ok()); +} + +} // namespace rustla2 + +int main(int argc, char** argv) { + google::InitGoogleLogging(argv[0]); + google::ParseCommandLineFlags(&argc, &argv, false); + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/src/components/ServiceSelect.jsx b/src/components/ServiceSelect.jsx index 0c26340..d37fc1f 100644 --- a/src/components/ServiceSelect.jsx +++ b/src/components/ServiceSelect.jsx @@ -8,6 +8,7 @@ const ServiceSelect = (props: React.ElementProps<'select'>) => + diff --git a/src/components/StreamEmbed.jsx b/src/components/StreamEmbed.jsx index 50b460f..13469ab 100644 --- a/src/components/StreamEmbed.jsx +++ b/src/components/StreamEmbed.jsx @@ -17,6 +17,8 @@ const getSrc = (channel: string, service: string): string | null => { return `https://player.angelthump.com/?channel=${channel}`; case 'facebook': return `https://www.facebook.com/plugins/video.php?href=https://www.facebook.com/facebook/videos/${channel}/&show_text=0`; + case 'okru': + return `https://ok.ru/videoembed/${channel}`; case 'smashcast': return `https://www.smashcast.tv/embed/${channel}?popout=true&autoplay=true`; case 'twitch-vod': @@ -39,6 +41,7 @@ type Service = | 'angelthump' | 'facebook' | 'm3u8' + | 'okru' | 'smashcast' | 'twitch-vod' | 'twitch' diff --git a/src/components/StreamThumbnail.jsx b/src/components/StreamThumbnail.jsx index 9f00c6e..26cb66c 100644 --- a/src/components/StreamThumbnail.jsx +++ b/src/components/StreamThumbnail.jsx @@ -38,6 +38,8 @@ const isDirectLinkSupported = (service) => { switch (service) { case "angelthump": return true; + case "okru": + return true; case "twitch-vod": return true; case "twitch": @@ -53,6 +55,8 @@ const getStreamServiceLink = (channel, service) => { switch (service) { case "angelthump": return "https://strims.gg/angelthump/" + channel; + case "okru": + return "https://ok.ru/video/" + channel; case "twitch-vod": return "https://www.twitch.tv/videos/" + channel; case "twitch": @@ -87,7 +91,8 @@ const StreamThumbnail = ({ let thumbnailProps = { className: 'thumbnail-image thumbnail-default-image' }; if (thumbnail) { const epochMinute = Math.floor(Date.now() / (THUMBNAIL_REFRESH_INTERVAL || 60000)); - const thumbnailUrl = live ? `${thumbnail}?${epochMinute}` : thumbnail; + const separator = thumbnail.includes('?') ? '&' : '?'; + const thumbnailUrl = live ? `${thumbnail}${separator}${epochMinute}` : thumbnail; thumbnailProps = { className: `thumbnail-image ${nsfw ? 'thumbnail-image-nsfw' : ''}`,