Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions api/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
5 changes: 3 additions & 2 deletions api/src/Channel.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,19 @@ 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"};
constexpr folly::StringPiece kUstreamService{"ustream"};
constexpr folly::StringPiece kYouTubePlaylistService{"youtube-playlist"};
constexpr folly::StringPiece kYouTubeService{"youtube"};

constexpr std::array<folly::StringPiece, 10> kServices{
constexpr std::array<folly::StringPiece, 11> kServices{
kAdvancedService, kAngelThumpService, kFacebookService,
kM3u8Service, kSmashcastService, kTwitchService,
kTwitchVODService, kUstreamService, kYouTubePlaylistService,
kYouTubeService};
kYouTubeService, kOkruService};

constexpr std::array<folly::StringPiece, 3> kCaseInsensitiveServices{
kAngelThumpService, kTwitchService, kUstreamService};
Expand Down
79 changes: 79 additions & 0 deletions api/src/OkruClient.cpp
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions api/src/OkruClient.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#pragma once

#include <string>

#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
19 changes: 19 additions & 0 deletions api/src/ServicePoller.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#include "AngelThumpClient.h"
#include "Config.h"
#include "OkruClient.h"

namespace rustla2 {

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions api/src/ServicePoller.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
93 changes: 93 additions & 0 deletions api/tests/OkruTest.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#include <gflags/gflags.h>
#include <glog/logging.h>
#include <gtest/gtest.h>

#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[] = {
"<html>Unavailable</html>",
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();
}
1 change: 1 addition & 0 deletions src/components/ServiceSelect.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const ServiceSelect = (props: React.ElementProps<'select'>) =>
<option value='angelthump'>AngelThump</option>
<option value='facebook'>Facebook</option>
<option value='m3u8'>m3u8</option>
<option value='okru'>OK.ru</option>
<option value='smashcast'>Smashcast</option>
<option value='twitch'>Twitch</option>
<option value='twitch-vod'>Twitch (VOD)</option>
Expand Down
3 changes: 3 additions & 0 deletions src/components/StreamEmbed.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand All @@ -39,6 +41,7 @@ type Service =
| 'angelthump'
| 'facebook'
| 'm3u8'
| 'okru'
| 'smashcast'
| 'twitch-vod'
| 'twitch'
Expand Down
7 changes: 6 additions & 1 deletion src/components/StreamThumbnail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const isDirectLinkSupported = (service) => {
switch (service) {
case "angelthump":
return true;
case "okru":
return true;
case "twitch-vod":
return true;
case "twitch":
Expand All @@ -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":
Expand Down Expand Up @@ -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' : ''}`,
Expand Down
Loading