diff --git a/api/CMakeLists.txt b/api/CMakeLists.txt index 97f3081a..2e37747d 100644 --- a/api/CMakeLists.txt +++ b/api/CMakeLists.txt @@ -173,6 +173,9 @@ target_link_libraries(smashcast_test PRIVATE ${TEST_LIB}) add_executable(streams_test tests/StreamsTest.cpp src/Streams.cpp + src/Users.cpp + src/Config.cpp + src/Strings.cpp src/Channel.cpp src/Status.cpp src/APIClient.cpp) @@ -188,3 +191,5 @@ add_test(angelthump angelthump_test) add_test(okru okru_test) add_test(smashcast smashcast_test) add_test(streams streams_test) +add_test(NAME users COMMAND users_test) +set_tests_properties(users PROPERTIES WORKING_DIRECTORY ${PROJECT_SOURCE_DIR}/tests) diff --git a/api/src/APIHTTPService.cpp b/api/src/APIHTTPService.cpp index 9dec6256..a098c18a 100644 --- a/api/src/APIHTTPService.cpp +++ b/api/src/APIHTTPService.cpp @@ -17,6 +17,7 @@ APIHTTPService::APIHTTPService(std::shared_ptr db) : db_(db) { "stream_path": {"type": "string"}, "service": {"type": "string"}, "channel": {"type": "string"}, + "stream_title_override": {"type": "string"}, "left_chat": {"type": "boolean"}, "show_hidden": {"type": "boolean"}, "show_dgg_chat": {"type": "boolean"}, @@ -145,6 +146,11 @@ void APIHTTPService::PostProfile(uWS::HttpResponse *res, HTTPRequest *req) { json::StringRef(input["stream_path"]), &status)); } + if (status.Ok() && input.HasMember("stream_title_override")) { + status = newUser->SetStreamTitleOverride( + json::StringRef(input["stream_title_override"])); + } + if (status.Ok()) { status = db_->GetUsers()->Save(newUser); } diff --git a/api/src/DB.h b/api/src/DB.h index 24ee8d25..61188e08 100644 --- a/api/src/DB.h +++ b/api/src/DB.h @@ -19,7 +19,7 @@ class DB { users_(std::make_shared(db_)), banned_streams_(std::make_shared(db_)), banned_ips_(std::make_shared(db_, "banned_ip_ranges")), - streams_(std::make_shared(db_)), + streams_(std::make_shared(db_, users_)), viewer_states_(std::make_shared(users_, streams_)) {} inline std::shared_ptr GetUsers() { return users_; } diff --git a/api/src/Streams.cpp b/api/src/Streams.cpp index c88beee7..95d048e1 100644 --- a/api/src/Streams.cpp +++ b/api/src/Streams.cpp @@ -2,8 +2,22 @@ #include +#include "Users.h" + namespace rustla2 { +std::string Stream::GetTitleOverride() const { + if (!users_ || !channel_->HasStreamPath()) { + return ""; + } + auto user = users_->GetByStreamPath(channel_->GetStreamPath()); + // Old stream cards must not inherit a title after a profile changes source. + if (!user || !ChannelEqual{}(*user->GetChannel(), *channel_)) { + return ""; + } + return user->GetStreamTitleOverride(); +} + void Stream::WriteAPIJSON( rapidjson::Writer *writer) const { boost::shared_lock read_lock(lock_); @@ -29,6 +43,8 @@ void Stream::WriteAPIJSON( writer->String(channel_->GetChannel()); writer->Key("title"); writer->String(title_); + writer->Key("title_override"); + writer->String(GetTitleOverride()); writer->Key("thumbnail"); writer->String(thumbnail_); writer->Key("url"); @@ -53,6 +69,8 @@ void Stream::WriteJSON( writer->String(channel_->GetStreamPath()); writer->Key("title"); writer->String(title_); + writer->Key("title_override"); + writer->String(GetTitleOverride()); writer->Key("thumbnail"); writer->String(thumbnail_); writer->Key("live"); @@ -221,8 +239,9 @@ bool Stream::SaveNew() { return true; } -Streams::Streams(sqlite::database db) - : db_(db), observers_(std::make_shared>()) { +Streams::Streams(sqlite::database db, std::shared_ptr users) + : db_(db), users_(users), + observers_(std::make_shared>()) { InitTable(); auto sql = R"sql( @@ -254,7 +273,7 @@ Streams::Streams(sqlite::database db) auto stream_channel = Channel::Create(channel, service, path); auto stream = std::make_shared( db_, observers_, id, stream_channel, nsfw, hidden, afk, promoted, title, - thumbnail, live, viewer_count, service_nsfw, removed); + thumbnail, live, viewer_count, service_nsfw, removed, users_); data_by_id_[stream->GetID()] = stream; data_by_channel_[stream_channel] = stream; @@ -353,7 +372,7 @@ void Streams::WriteStreamsJSON( } std::shared_ptr Streams::Emplace(const Channel &channel) { - auto stream = std::make_shared(db_, observers_, channel); + auto stream = std::make_shared(db_, observers_, channel, users_); { boost::unique_lock write_lock(lock_); diff --git a/api/src/Streams.h b/api/src/Streams.h index 2402cfb0..cdf391c9 100644 --- a/api/src/Streams.h +++ b/api/src/Streams.h @@ -20,6 +20,8 @@ namespace rustla2 { +class Users; + // Cap stream IDs to 48 bits for JS const uint64_t kMaxStreamID = 0xFFFFFFFFFFF; @@ -30,7 +32,8 @@ class Stream { bool hidden = false, bool afk = false, bool promoted = false, const std::string &title = "", const std::string &thumbnail = "", const bool live = false, const uint64_t viewer_count = 0, - const bool service_nsfw = false, const bool removed = false) + const bool service_nsfw = false, const bool removed = false, + std::shared_ptr users = nullptr) : db_(db), observers_(observers), viewer_ips_(std::make_shared()), @@ -45,11 +48,14 @@ class Stream { afk_(afk), promoted_(promoted), viewer_count_(viewer_count), - service_nsfw_(service_nsfw) {} + service_nsfw_(service_nsfw), + users_(users) {} Stream(sqlite::database db, std::shared_ptr> observers, - const Channel &channel) - : Stream(db, observers, ChannelHash{}(channel)&kMaxStreamID, channel) {} + const Channel &channel, std::shared_ptr users = nullptr) + : Stream(db, observers, ChannelHash{}(channel)&kMaxStreamID, channel) { + users_ = users; + } inline uint64_t GetID() const { boost::shared_lock read_lock(lock_); @@ -231,6 +237,10 @@ class Stream { } private: + // Called while holding the stream lock. Resolve the current profile so edits + // appear in the next directory broadcast without changing provider metadata. + std::string GetTitleOverride() const; + inline void ResetUpdatedTime() { update_time_ = std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()) @@ -257,6 +267,7 @@ class Stream { uint64_t afk_count_{0}; uint64_t reset_time_{0}; uint64_t update_time_{0}; + std::shared_ptr users_; friend std::ostream &operator<<(std::ostream &os, const Stream &stream); }; @@ -293,7 +304,7 @@ struct IsNotRemoved { class Streams { public: - explicit Streams(sqlite::database db); + explicit Streams(sqlite::database db, std::shared_ptr users = nullptr); void InitTable(); @@ -351,6 +362,7 @@ class Streams { private: sqlite::database db_; + std::shared_ptr users_; std::shared_ptr> observers_; boost::shared_mutex lock_; std::unordered_map> data_by_id_; diff --git a/api/src/Users.cpp b/api/src/Users.cpp index 6f4c5f7b..16d8d536 100644 --- a/api/src/Users.cpp +++ b/api/src/Users.cpp @@ -1,6 +1,7 @@ #include "Users.h" #include +#include #include #include @@ -58,6 +59,8 @@ std::string User::GetProfileJSON() { writer.Bool(show_dgg_chat_); writer.Key("enable_public_state"); writer.Bool(enable_public_state_); + writer.Key("stream_title_override"); + writer.String(stream_title_override_); writer.EndObject(); return buf.GetString(); @@ -167,6 +170,28 @@ bool User::SetChannel(const Channel &channel) { return true; } +Status User::SetStreamTitleOverride(const std::string &title) { + const auto trimmed = boost::trim_copy(title); + rapidjson::MemoryStream input(trimmed.data(), trimmed.size()); + size_t length = 0; + while (input.Tell() < trimmed.size()) { + unsigned codepoint; + if (!rapidjson::UTF8<>::Decode(input, &codepoint) || codepoint < 32 || + (codepoint >= 127 && codepoint <= 159)) { + return Status(StatusCode::VALIDATION_ERROR, + "Stream title must be a single line of valid text."); + } + if (++length > 120) { + return Status(StatusCode::VALIDATION_ERROR, + "Stream title must be 120 characters or fewer."); + } + } + + boost::unique_lock write_lock(lock_); + stream_title_override_ = trimmed; + return Status::OK; +} + bool User::SetLeftChat(bool left_chat) { boost::unique_lock write_lock(lock_); left_chat_ = left_chat; @@ -219,13 +244,14 @@ bool User::Save() { `show_hidden` = ?, `show_dgg_chat` = ?, `enable_public_state` = ?, + `stream_title_override` = ?, `updated_at` = datetime() WHERE `id` = ? )sql"; db_ << sql << name_ << channel_->GetStreamPath() << channel_->GetService() << channel_->GetChannel() << last_ip_ << last_seen_ << left_chat_ << is_admin_ << show_hidden_ << show_dgg_chat_ << enable_public_state_ - << GetIDString(); + << stream_title_override_ << GetIDString(); } catch (const sqlite::sqlite_exception &e) { LOG(ERROR) << "error updating user " << this << ", " << "error: " << e.what() << ", " @@ -255,6 +281,7 @@ bool User::SaveNew() { `show_hidden`, `show_dgg_chat`, `enable_public_state`, + `stream_title_override`, `ban_reason`, `created_at`, `updated_at` @@ -274,6 +301,7 @@ bool User::SaveNew() { ?, ?, ?, + ?, '', datetime(), datetime() @@ -282,7 +310,8 @@ bool User::SaveNew() { db_ << sql << GetIDString() << twitch_id_ << channel_->GetChannel() << name_ << channel_->GetStreamPath() << channel_->GetService() << channel_->GetChannel() << last_ip_ << last_seen_ << left_chat_ - << is_admin_ << show_hidden_ << show_dgg_chat_ << enable_public_state_; + << is_admin_ << show_hidden_ << show_dgg_chat_ << enable_public_state_ + << stream_title_override_; } catch (const sqlite::sqlite_exception &e) { LOG(ERROR) << "error creating user " << this << ", " << "error: " << e.what() << ", " @@ -327,7 +356,8 @@ Users::Users(sqlite::database db) : db_(db) { `is_admin`, `show_hidden`, `show_dgg_chat`, - `enable_public_state` + `enable_public_state`, + `stream_title_override` FROM `users` )sql"; auto query = db_ << sql; @@ -338,12 +368,14 @@ Users::Users(sqlite::database db) : db_(db) { const std::string &last_ip, const time_t last_seen, const bool left_chat, const bool is_admin, const bool show_hidden, const bool show_dgg_chat, - const bool enable_public_state) { + const bool enable_public_state, + const std::string &stream_title_override) { boost::uuids::string_generator to_uuid; auto user_channel = Channel::Create(channel, service, stream_path); auto user = std::make_shared( db_, to_uuid(id), twitch_id, name, user_channel, last_ip, last_seen, - left_chat, is_admin, show_hidden, show_dgg_chat, enable_public_state); + left_chat, is_admin, show_hidden, show_dgg_chat, enable_public_state, + stream_title_override); data_by_id_[user->GetID()] = user; data_by_twitch_id_[user->GetTwitchID()] = user; @@ -377,11 +409,26 @@ void Users::InitTable() { `show_hidden` TINYINT(1) DEFAULT 0, `show_dgg_chat` TINYINT(1) DEFAULT 0, `enable_public_state` TINYINT(1) DEFAULT 1, + `stream_title_override` TEXT NOT NULL DEFAULT '', UNIQUE (`id`), UNIQUE (`twitch_id`) ) )sql"; db_ << sql; + + // CREATE TABLE does not add columns to existing databases. + bool has_title_override = false; + db_ << "PRAGMA table_info(users)" >> + [&](int, const std::string &name, const std::string &, int, + std::unique_ptr, int) { + if (name == "stream_title_override") { + has_title_override = true; + } + }; + if (!has_title_override) { + db_ << "ALTER TABLE users ADD COLUMN stream_title_override " + "TEXT NOT NULL DEFAULT ''"; + } } std::shared_ptr Users::GetByID(const boost::uuids::uuid &id) { diff --git a/api/src/Users.h b/api/src/Users.h index 89f09228..fc958683 100644 --- a/api/src/Users.h +++ b/api/src/Users.h @@ -28,7 +28,8 @@ class User { const int64_t twitch_id, const std::string &name, const Channel &channel, const std::string &last_ip, const time_t last_seen, const bool left_chat, const bool is_admin, const bool show_hidden, const bool show_dgg_chat, - const bool enable_public_state) + const bool enable_public_state, + const std::string &stream_title_override = "") : db_(db), id_(id), twitch_id_(twitch_id), @@ -40,7 +41,8 @@ class User { is_admin_(is_admin), show_hidden_(show_hidden), show_dgg_chat_(show_dgg_chat), - enable_public_state_(enable_public_state) {} + enable_public_state_(enable_public_state), + stream_title_override_(stream_title_override) {} User(sqlite::database db, const uint64_t twitch_id, const Channel &channel, const std::string &last_ip) @@ -68,7 +70,8 @@ class User { is_admin_(user.is_admin_), show_hidden_(user.show_hidden_), show_dgg_chat_(user.show_dgg_chat_), - enable_public_state_(user.enable_public_state_) {} + enable_public_state_(user.enable_public_state_), + stream_title_override_(user.stream_title_override_) {} inline boost::uuids::uuid GetID() { boost::shared_lock read_lock(lock_); @@ -130,6 +133,11 @@ class User { return enable_public_state_; } + inline std::string GetStreamTitleOverride() { + boost::shared_lock read_lock(lock_); + return stream_title_override_; + } + std::string GetStreamJSON(); std::string GetUsernameJSON(); @@ -140,6 +148,8 @@ class User { Status SetName(const std::string &name); + Status SetStreamTitleOverride(const std::string &title); + bool SetChannel(const Channel &channel); bool SetLeftChat(bool left_chat); @@ -172,6 +182,7 @@ class User { bool show_hidden_; bool show_dgg_chat_; bool enable_public_state_; + std::string stream_title_override_; friend class Users; friend std::ostream &operator<<(std::ostream &os, const User &user); diff --git a/api/tests/StreamsTest.cpp b/api/tests/StreamsTest.cpp index 08962b66..ae6cab1f 100644 --- a/api/tests/StreamsTest.cpp +++ b/api/tests/StreamsTest.cpp @@ -5,6 +5,7 @@ #include "../src/Observer.h" #include "../src/Streams.h" +#include "../src/Users.h" #include "rapidjson/document.h" namespace rustla2 { @@ -94,6 +95,85 @@ TEST(StreamsTest, TestGetAPIJSON) { EXPECT_EQ(doc["stream_list"].GetArray().Size(), 1); EXPECT_EQ(doc["streams"].GetObject()["/youtube/test"].GetUint64(), 1); } +rapidjson::Document StreamJSON(std::shared_ptr stream, bool api = false) { + rapidjson::StringBuffer buf; + rapidjson::Writer writer(buf); + if (api) stream->WriteAPIJSON(&writer); + else stream->WriteJSON(&writer); + rapidjson::Document doc; + doc.Parse(buf.GetString()); + return doc; +} + +TEST(StreamsTest, ProfileTitleUpdatesWithoutOverwritingProviderTitle) { + sqlite::database db(":memory:"); + auto users = std::make_shared(db); + Streams streams(db, users); + auto channel = Channel::Create("test", "angelthump", "movies"); + auto user = users->Emplace(1, channel, "127.0.0.1"); + ASSERT_NE(user, nullptr); + ASSERT_TRUE(user->SetStreamTitleOverride("Movie night").Ok()); + ASSERT_TRUE(users->Save(user).Ok()); + auto stream = streams.Emplace(channel); + stream->SetTitle("Provider title"); + + for (bool api : {false, true}) { + auto doc = StreamJSON(stream, api); + EXPECT_STREQ(doc["title_override"].GetString(), "Movie night"); + EXPECT_STREQ(doc["title"].GetString(), "Provider title"); + } + + // Simulate a poll and a profile edit while the card is already present. + stream->SetTitle("Updated provider title"); + auto edited = std::make_shared(*user); + ASSERT_TRUE(edited->SetStreamTitleOverride("Another movie").Ok()); + ASSERT_TRUE(users->Save(edited).Ok()); + EXPECT_STREQ(StreamJSON(stream)["title_override"].GetString(), "Another movie"); + ASSERT_TRUE(stream->Save()); + auto reloaded_users = std::make_shared(db); + Streams reloaded(db, reloaded_users); + EXPECT_STREQ(StreamJSON(reloaded.GetByChannel(channel))["title_override"].GetString(), + "Another movie"); + + ASSERT_TRUE(edited->SetStreamTitleOverride("").Ok()); + ASSERT_TRUE(users->Save(edited).Ok()); + auto cleared = StreamJSON(stream); + EXPECT_STREQ(cleared["title_override"].GetString(), ""); + EXPECT_STREQ(cleared["title"].GetString(), "Updated provider title"); +} + +TEST(StreamsTest, ProfileTitlesAreScopedToTheirOwnStream) { + sqlite::database db(":memory:"); + auto users = std::make_shared(db); + Streams streams(db, users); + auto channel = Channel::Create("test", "angelthump", "movies"); + auto other_channel = Channel::Create("test", "angelthump", "other"); + auto user = users->Emplace(1, channel, "127.0.0.1"); + auto other = users->Emplace(2, other_channel, "127.0.0.1"); + ASSERT_NE(user, nullptr); + ASSERT_NE(other, nullptr); + ASSERT_TRUE(user->SetStreamTitleOverride("Movie night").Ok()); + ASSERT_TRUE(users->Save(user).Ok()); + ASSERT_TRUE(other->SetStreamTitleOverride("Other title").Ok()); + ASSERT_TRUE(users->Save(other).Ok()); + auto stream = streams.Emplace(channel); + EXPECT_STREQ(StreamJSON(stream)["title_override"].GetString(), "Movie night"); + EXPECT_STREQ(StreamJSON(streams.Emplace(other_channel))["title_override"].GetString(), + "Other title"); + EXPECT_STREQ(StreamJSON(streams.Emplace(Channel::Create("test", "angelthump"))) + ["title_override"].GetString(), ""); + EXPECT_STREQ(StreamJSON(streams.Emplace(Channel::Create("test", "twitch", "movies"))) + ["title_override"].GetString(), ""); + + auto edited = std::make_shared(*user); + auto new_channel = Channel::Create("different", "angelthump", "movies"); + edited->SetChannel(new_channel); + ASSERT_TRUE(users->Save(edited).Ok()); + EXPECT_STREQ(StreamJSON(stream)["title_override"].GetString(), ""); + EXPECT_STREQ(StreamJSON(streams.Emplace(new_channel))["title_override"].GetString(), + "Movie night"); +} + } // namespace rustla2 int main(int argc, char **argv) { diff --git a/api/tests/UsersTest.cpp b/api/tests/UsersTest.cpp index c9410618..4459ae45 100644 --- a/api/tests/UsersTest.cpp +++ b/api/tests/UsersTest.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include "../src/Config.h" #include "../src/Users.h" @@ -25,6 +26,96 @@ TEST(UsersTest, TestSetName) { } } +TEST(UsersTest, StreamTitleValidation) { + sqlite::database db(":memory:"); + User user(db, 1, Channel::Create("test", "angelthump"), "127.0.0.1"); + + EXPECT_TRUE(user.SetStreamTitleOverride(" Movie night ").Ok()); + EXPECT_EQ(user.GetStreamTitleOverride(), "Movie night"); + EXPECT_TRUE(user.SetStreamTitleOverride(u8"Кино 🎬").Ok()); + EXPECT_EQ(user.GetStreamTitleOverride(), u8"Кино 🎬"); + std::string unicode_title; + for (int i = 0; i < 120; ++i) unicode_title += u8"🎬"; + EXPECT_TRUE(user.SetStreamTitleOverride(unicode_title).Ok()); + EXPECT_FALSE(user.SetStreamTitleOverride(unicode_title + "x").Ok()); + EXPECT_EQ(user.GetStreamTitleOverride(), unicode_title); + EXPECT_FALSE(user.SetStreamTitleOverride("two\nlines").Ok()); + EXPECT_FALSE(user.SetStreamTitleOverride(std::string("a\0b", 3)).Ok()); + EXPECT_FALSE(user.SetStreamTitleOverride(std::string("\xff", 1)).Ok()); + EXPECT_TRUE(user.SetStreamTitleOverride(" ").Ok()); + EXPECT_EQ(user.GetStreamTitleOverride(), ""); +} + +TEST(UsersTest, StreamTitlePersistsAndClears) { + sqlite::database db(":memory:"); + Users users(db); + auto user = users.Emplace(1, Channel::Create("test", "angelthump", "movies"), + "127.0.0.1"); + ASSERT_NE(user, nullptr); + EXPECT_EQ(user->GetStreamTitleOverride(), ""); + auto edited = std::make_shared(*user); + ASSERT_TRUE(edited->SetStreamTitleOverride("Movie night").Ok()); + ASSERT_TRUE(users.Save(edited).Ok()); + + Users reloaded(db); + auto saved = reloaded.GetByID(user->GetID()); + ASSERT_NE(saved, nullptr); + EXPECT_EQ(saved->GetStreamTitleOverride(), "Movie night"); + rapidjson::Document profile; + profile.Parse(saved->GetProfileJSON().c_str()); + EXPECT_STREQ(profile["stream_title_override"].GetString(), "Movie night"); + EXPECT_EQ(User(*saved).GetStreamTitleOverride(), "Movie night"); + + ASSERT_TRUE(saved->SetStreamTitleOverride("").Ok()); + ASSERT_TRUE(reloaded.Save(saved).Ok()); + Users cleared(db); + EXPECT_EQ(cleared.GetByID(user->GetID())->GetStreamTitleOverride(), ""); +} + +TEST(UsersTest, ExistingDatabaseUpgradePreservesProfilesAndRunsOnce) { + sqlite::database db(":memory:"); + // Schema from before stream title overrides were added. + db << R"sql( + CREATE TABLE users ( + id CHAR(36) NOT NULL, twitch_id UNSIGNED BIGINT NOT NULL, + twitch_username VARCHAR(32) NOT NULL, name VARCHAR(32) NOT NULL, + stream_path VARCHAR(255) NOT NULL, service VARCHAR(255) NOT NULL, + channel VARCHAR(255) NOT NULL, last_ip VARCHAR(255) NOT NULL, + last_seen DATETIME NOT NULL, left_chat TINYINT(1) DEFAULT 0, + is_banned TINYINT(1) NOT NULL DEFAULT 0, ban_reason VARCHAR(255), + created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, + is_admin TINYINT(1) DEFAULT 0, show_hidden TINYINT(1) DEFAULT 0, + show_dgg_chat TINYINT(1) DEFAULT 0, + enable_public_state TINYINT(1) DEFAULT 1, + UNIQUE (id), UNIQUE (twitch_id) + ) + )sql"; + db << R"sql( + INSERT INTO users (id, twitch_id, twitch_username, name, stream_path, + service, channel, last_ip, last_seen, created_at, updated_at, is_admin) + VALUES ('11111111-2222-4333-8444-555555555555', 1, 'existing', 'existing', + 'movies', 'angelthump', 'test', '127.0.0.1', datetime(), datetime(), + datetime(), 1) + )sql"; + + Users upgraded(db); + auto user = upgraded.GetByTwitchID(1); + ASSERT_NE(user, nullptr); + EXPECT_EQ(user->GetName(), "existing"); + EXPECT_EQ(user->GetChannel()->GetStreamPath(), "movies"); + EXPECT_TRUE(user->GetIsAdmin()); + EXPECT_EQ(user->GetStreamTitleOverride(), ""); + ASSERT_TRUE(user->SetStreamTitleOverride("Saved after upgrade").Ok()); + ASSERT_TRUE(upgraded.Save(user).Ok()); + + Users restarted(db); + EXPECT_EQ(restarted.GetByTwitchID(1)->GetStreamTitleOverride(), + "Saved after upgrade"); + int count = 0; + db << "SELECT COUNT(*) FROM users" >> count; + EXPECT_EQ(count, 1); +} + } // namespace rustla2 int main(int argc, char **argv) { diff --git a/src/components/Profile.jsx b/src/components/Profile.jsx index 3e353242..632cea15 100644 --- a/src/components/Profile.jsx +++ b/src/components/Profile.jsx @@ -35,6 +35,7 @@ const Profile = ({ service: event.target.elements.service.value, channel: event.target.elements.channel.value, stream_path: event.target.elements.stream_path.value, + stream_title_override: event.target.elements.stream_title_override.value, username: event.target.elements.username.value, enable_public_state: event.target.elements.enable_public_state.checked, left_chat: event.target.elements.left_chat.checked, @@ -100,6 +101,21 @@ const Profile = ({ /> +
+ + + + Custom title for your stream card. Leave blank to use the default title. + +
{ const color = generateColor(channel + service); const url = overrustle_id ? overrustle_id : `${service}/${channel}`; - const text = getStreamTitle(overrustle_id, channel, title, service); + const text = title_override || getStreamTitle(overrustle_id, channel, title, service); let thumbnailProps = { className: 'thumbnail-image thumbnail-default-image' }; if (thumbnail) { @@ -158,6 +159,7 @@ StreamThumbnail.propTypes = { service: PropTypes.string.isRequired, thumbnail: PropTypes.string, title: PropTypes.string, + title_override: PropTypes.string, isAdmin: PropTypes.bool, }; diff --git a/src/redux/types.js b/src/redux/types.js index a973a1f4..ceb9b8ce 100644 --- a/src/redux/types.js +++ b/src/redux/types.js @@ -24,6 +24,7 @@ type Stream = {| +service: string, +thumbnail: string, +title: string, + +title_override?: string, +viewers: number |};