diff --git a/.clang-tidy b/.clang-tidy index d75c5dce2..2610cd3e7 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -49,6 +49,8 @@ CheckOptions: value: CamelCase - key: readability-identifier-naming.GlobalConstantCase value: UPPER_CASE + - key: readability-identifier-naming.GlobalVariableCase + value: UPPER_CASE - key: readability-identifier-naming.VariableCase value: camelBack - key: readability-implicit-bool-conversion.AllowPointerConditions diff --git a/CHANGELOG.md b/CHANGELOG.md index 7767bc1a8..7a64364ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Minor: Added option to subscribe to and unsubscribe from reply threads. (#4680) - Minor: Added a message for when Chatterino joins a channel (#4616) - Minor: Add pin action to usercards and reply threads. (#4692) +- Minor: Stream status requests are now batched. (#4713) - Bugfix: Fixed generation of crashdumps by the browser-extension process when the browser was closed. (#4667) - Bugfix: Fix spacing issue with mentions inside RTL text. (#4677) - Bugfix: Fixed a crash when opening and closing a reply thread and switching the user. (#4675) diff --git a/mocks/include/mocks/EmptyApplication.hpp b/mocks/include/mocks/EmptyApplication.hpp index 4dabc4ffa..afb53d5a9 100644 --- a/mocks/include/mocks/EmptyApplication.hpp +++ b/mocks/include/mocks/EmptyApplication.hpp @@ -76,6 +76,11 @@ public: { return nullptr; } + + ITwitchLiveController *getTwitchLiveController() override + { + return nullptr; + } }; } // namespace chatterino::mock diff --git a/mocks/include/mocks/Helix.hpp b/mocks/include/mocks/Helix.hpp index 45b1017ee..2d4d01aad 100644 --- a/mocks/include/mocks/Helix.hpp +++ b/mocks/include/mocks/Helix.hpp @@ -86,6 +86,12 @@ public: std::function finallyCallback), (override)); + MOCK_METHOD(void, fetchChannels, + (QStringList userIDs, + ResultCallback> successCallback, + HelixFailureCallback failureCallback), + (override)); + MOCK_METHOD(void, getChannel, (QString broadcasterId, ResultCallback successCallback, diff --git a/src/Application.cpp b/src/Application.cpp index 248cb0d86..683a873af 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -14,6 +14,7 @@ # include "controllers/plugins/PluginController.hpp" #endif #include "controllers/sound/SoundController.hpp" +#include "controllers/twitch/LiveController.hpp" #include "controllers/userdata/UserDataController.hpp" #include "debug/AssertInGuiThread.hpp" #include "messages/Message.hpp" @@ -88,6 +89,7 @@ Application::Application(Settings &_settings, Paths &_paths) , seventvBadges(&this->emplace()) , userData(&this->emplace()) , sound(&this->emplace()) + , twitchLiveController(&this->emplace()) #ifdef CHATTERINO_HAVE_PLUGINS , plugins(&this->emplace()) #endif @@ -245,6 +247,11 @@ IUserDataController *Application::getUserData() return this->userData; } +ITwitchLiveController *Application::getTwitchLiveController() +{ + return this->twitchLiveController; +} + ITwitchIrcServer *Application::getTwitch() { return this->twitch; diff --git a/src/Application.hpp b/src/Application.hpp index 6917190bd..1d637cda1 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -23,6 +23,8 @@ class HotkeyController; class IUserDataController; class UserDataController; class SoundController; +class ITwitchLiveController; +class TwitchLiveController; #ifdef CHATTERINO_HAVE_PLUGINS class PluginController; #endif @@ -62,6 +64,7 @@ public: virtual ChatterinoBadges *getChatterinoBadges() = 0; virtual FfzBadges *getFfzBadges() = 0; virtual IUserDataController *getUserData() = 0; + virtual ITwitchLiveController *getTwitchLiveController() = 0; }; class Application : public IApplication @@ -101,6 +104,10 @@ public: UserDataController *const userData{}; SoundController *const sound{}; +private: + TwitchLiveController *const twitchLiveController{}; + +public: #ifdef CHATTERINO_HAVE_PLUGINS PluginController *const plugins{}; #endif @@ -154,6 +161,7 @@ public: return this->ffzBadges; } IUserDataController *getUserData() override; + ITwitchLiveController *getTwitchLiveController() override; pajlada::Signals::NoArgSignal streamerModeChanged; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c095aa00f..4e30d67fd 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -167,13 +167,16 @@ set(SOURCE_FILES controllers/plugins/LuaUtilities.cpp controllers/plugins/LuaUtilities.hpp + controllers/sound/SoundController.cpp + controllers/sound/SoundController.hpp + + controllers/twitch/LiveController.cpp + controllers/twitch/LiveController.hpp + controllers/userdata/UserDataController.cpp controllers/userdata/UserDataController.hpp controllers/userdata/UserData.hpp - controllers/sound/SoundController.cpp - controllers/sound/SoundController.hpp - debug/Benchmark.cpp debug/Benchmark.hpp diff --git a/src/common/QLogging.cpp b/src/common/QLogging.cpp index a3168a993..163debfd5 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -48,6 +48,8 @@ Q_LOGGING_CATEGORY(chatterinoStreamlink, "chatterino.streamlink", logThreshold); Q_LOGGING_CATEGORY(chatterinoTheme, "chatterino.theme", logThreshold); Q_LOGGING_CATEGORY(chatterinoTokenizer, "chatterino.tokenizer", logThreshold); Q_LOGGING_CATEGORY(chatterinoTwitch, "chatterino.twitch", logThreshold); +Q_LOGGING_CATEGORY(chatterinoTwitchLiveController, + "chatterino.twitch.livecontroller", logThreshold); Q_LOGGING_CATEGORY(chatterinoUpdate, "chatterino.update", logThreshold); Q_LOGGING_CATEGORY(chatterinoWebsocket, "chatterino.websocket", logThreshold); Q_LOGGING_CATEGORY(chatterinoWidget, "chatterino.widget", logThreshold); diff --git a/src/common/QLogging.hpp b/src/common/QLogging.hpp index c2d0ae2ca..4531ce89e 100644 --- a/src/common/QLogging.hpp +++ b/src/common/QLogging.hpp @@ -37,6 +37,7 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoStreamlink); Q_DECLARE_LOGGING_CATEGORY(chatterinoTheme); Q_DECLARE_LOGGING_CATEGORY(chatterinoTokenizer); Q_DECLARE_LOGGING_CATEGORY(chatterinoTwitch); +Q_DECLARE_LOGGING_CATEGORY(chatterinoTwitchLiveController); Q_DECLARE_LOGGING_CATEGORY(chatterinoUpdate); Q_DECLARE_LOGGING_CATEGORY(chatterinoWebsocket); Q_DECLARE_LOGGING_CATEGORY(chatterinoWidget); diff --git a/src/controllers/twitch/LiveController.cpp b/src/controllers/twitch/LiveController.cpp new file mode 100644 index 000000000..c531ebe6f --- /dev/null +++ b/src/controllers/twitch/LiveController.cpp @@ -0,0 +1,190 @@ +#include "controllers/twitch/LiveController.hpp" + +#include "common/QLogging.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "util/Helpers.hpp" + +#include + +namespace { + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +const auto &LOG = chatterinoTwitchLiveController; + +} // namespace + +namespace chatterino { + +TwitchLiveController::TwitchLiveController() +{ + QObject::connect(&this->refreshTimer, &QTimer::timeout, [this] { + this->request(); + }); + this->refreshTimer.start(TwitchLiveController::REFRESH_INTERVAL); + + QObject::connect(&this->immediateRequestTimer, &QTimer::timeout, [this] { + QStringList channelIDs; + + { + std::unique_lock immediateRequestsLock( + this->immediateRequestsMutex); + for (const auto &channelID : this->immediateRequests) + { + channelIDs.append(channelID); + } + this->immediateRequests.clear(); + } + + if (channelIDs.isEmpty()) + { + return; + } + + this->request(channelIDs); + }); + this->immediateRequestTimer.start( + TwitchLiveController::IMMEDIATE_REQUEST_INTERVAL); +} + +void TwitchLiveController::add(const std::shared_ptr &newChannel) +{ + assert(newChannel != nullptr); + + const auto channelID = newChannel->roomId(); + assert(!channelID.isEmpty()); + + { + std::unique_lock lock(this->channelsMutex); + this->channels[channelID] = newChannel; + } + + { + std::unique_lock immediateRequestsLock(this->immediateRequestsMutex); + this->immediateRequests.emplace(channelID); + } +} + +void TwitchLiveController::request(std::optional optChannelIDs) +{ + QStringList channelIDs; + + if (optChannelIDs) + { + channelIDs = *optChannelIDs; + } + else + { + std::shared_lock lock(this->channelsMutex); + + for (const auto &channelList : this->channels) + { + channelIDs.append(channelList.first); + } + } + + if (channelIDs.isEmpty()) + { + return; + } + + auto batches = + splitListIntoBatches(channelIDs, TwitchLiveController::BATCH_SIZE); + + qCDebug(LOG) << "Make" << batches.size() << "requests"; + + for (const auto &batch : batches) + { + // TODO: Explore making this concurrent + getHelix()->fetchStreams( + batch, {}, + [this, batch{batch}](const auto &streams) { + std::unordered_map> results; + + for (const auto &channelID : batch) + { + results[channelID] = std::nullopt; + } + + for (const auto &stream : streams) + { + results[stream.userId] = stream; + } + + QStringList deadChannels; + + { + std::shared_lock lock(this->channelsMutex); + for (const auto &result : results) + { + auto it = this->channels.find(result.first); + if (it != channels.end()) + { + if (auto channel = it->second.lock(); channel) + { + channel->updateStreamStatus(result.second); + } + else + { + deadChannels.append(result.first); + } + } + } + } + + if (!deadChannels.isEmpty()) + { + std::unique_lock lock(this->channelsMutex); + for (const auto &deadChannel : deadChannels) + { + this->channels.erase(deadChannel); + } + } + }, + [] { + qCWarning(LOG) << "Failed stream check request"; + }, + [] {}); + + // TODO: Explore making this concurrent + getHelix()->fetchChannels( + batch, + [this, batch{batch}](const auto &helixChannels) { + QStringList deadChannels; + + { + std::shared_lock lock(this->channelsMutex); + for (const auto &helixChannel : helixChannels) + { + auto it = this->channels.find(helixChannel.userId); + if (it != this->channels.end()) + { + if (auto channel = it->second.lock(); channel) + { + channel->updateStreamTitle(helixChannel.title); + channel->updateDisplayName(helixChannel.name); + } + else + { + deadChannels.append(helixChannel.userId); + } + } + } + } + + if (!deadChannels.isEmpty()) + { + std::unique_lock lock(this->channelsMutex); + for (const auto &deadChannel : deadChannels) + { + this->channels.erase(deadChannel); + } + } + }, + [] { + qCWarning(LOG) << "Failed stream check request"; + }); + } +} + +} // namespace chatterino diff --git a/src/controllers/twitch/LiveController.hpp b/src/controllers/twitch/LiveController.hpp new file mode 100644 index 000000000..79befd62d --- /dev/null +++ b/src/controllers/twitch/LiveController.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include "common/Singleton.hpp" +#include "util/QStringHash.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace chatterino { + +class TwitchChannel; + +class ITwitchLiveController +{ +public: + virtual ~ITwitchLiveController() = default; + + virtual void add(const std::shared_ptr &newChannel) = 0; +}; + +class TwitchLiveController : public ITwitchLiveController, public Singleton +{ +public: + // Controls how often all channels have their stream status refreshed + static constexpr std::chrono::seconds REFRESH_INTERVAL{30}; + + // Controls how quickly new channels have their stream status loaded + static constexpr std::chrono::seconds IMMEDIATE_REQUEST_INTERVAL{1}; + + /** + * How many channels to include in a single request + * + * Should not be more than 100 + **/ + static constexpr int BATCH_SIZE{100}; + + TwitchLiveController(); + + // Add a Twitch channel to be queried for live status + // A request is made within a few seconds if this is the first time this channel is added + void add(const std::shared_ptr &newChannel) override; + +private: + /** + * Run batched Helix Channels & Stream requests for channels + * + * If a list of channel IDs is passed to request, we only make a request for those channels + * + * If no list of channels is passed to request (the default behaviour), we make requests for all channels + * in the `channels` map. + **/ + void request(std::optional optChannelIDs = std::nullopt); + + /** + * List of channel IDs pointing to their Twitch Channel + * + * These channels will have their stream status updated every REFRESH_INTERVAL seconds + **/ + std::unordered_map> channels; + std::shared_mutex channelsMutex; + + /** + * List of channels that need an immediate live status update + * + * These channels will have their stream status updated after at most IMMEDIATE_REQUEST_INTERVAL seconds + **/ + std::unordered_set immediateRequests; + std::mutex immediateRequestsMutex; + + /** + * Timer responsible for refreshing `channels` + **/ + QTimer refreshTimer; + + /** + * Timer responsible for refreshing `immediateRequests` + **/ + QTimer immediateRequestTimer; +}; + +} // namespace chatterino diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 4a81da243..4d88bf5f7 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -8,6 +8,7 @@ #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/notifications/NotificationController.hpp" +#include "controllers/twitch/LiveController.hpp" #include "messages/Emote.hpp" #include "messages/Image.hpp" #include "messages/Link.hpp" @@ -98,14 +99,14 @@ TwitchChannel::TwitchChannel(const QString &name) // room id loaded -> refresh live status this->roomIdChanged.connect([this]() { this->refreshPubSub(); - this->refreshTitle(); - this->refreshLiveStatus(); this->refreshBadges(); this->refreshCheerEmotes(); this->refreshFFZChannelEmotes(false); this->refreshBTTVChannelEmotes(false); this->refreshSevenTVChannelEmotes(false); this->joinBttvChannel(); + getIApp()->getTwitchLiveController()->add( + std::dynamic_pointer_cast(shared_from_this())); }); this->connected.connect([this]() { @@ -151,6 +152,85 @@ TwitchChannel::TwitchChannel(const QString &name) }); this->threadClearTimer_.start(5 * 60 * 1000); + auto onLiveStatusChanged = [this](auto isLive) { + if (isLive) + { + qCDebug(chatterinoTwitch) + << "[TwitchChannel" << this->getName() << "] Online"; + if (getApp()->notifications->isChannelNotified(this->getName(), + Platform::Twitch)) + { + if (Toasts::isEnabled()) + { + getApp()->toasts->sendChannelNotification( + this->getName(), this->accessStreamStatus()->title, + Platform::Twitch); + } + if (getSettings()->notificationPlaySound) + { + getApp()->notifications->playSound(); + } + if (getSettings()->notificationFlashTaskbar) + { + getApp()->windows->sendAlert(); + } + } + // Channel live message + MessageBuilder builder; + TwitchMessageBuilder::liveSystemMessage(this->getDisplayName(), + &builder); + this->addMessage(builder.release()); + + // Message in /live channel + MessageBuilder builder2; + TwitchMessageBuilder::liveMessage(this->getDisplayName(), + &builder2); + getApp()->twitch->liveChannel->addMessage(builder2.release()); + + // Notify on all channels with a ping sound + if (getSettings()->notificationOnAnyChannel && + !(isInStreamerMode() && + getSettings()->streamerModeSuppressLiveNotifications)) + { + getApp()->notifications->playSound(); + } + } + else + { + qCDebug(chatterinoTwitch) + << "[TwitchChannel" << this->getName() << "] Offline"; + // Channel offline message + MessageBuilder builder; + TwitchMessageBuilder::offlineSystemMessage(this->getDisplayName(), + &builder); + this->addMessage(builder.release()); + + // "delete" old 'CHANNEL is live' message + LimitedQueueSnapshot snapshot = + getApp()->twitch->liveChannel->getMessageSnapshot(); + int snapshotLength = snapshot.size(); + + // MSVC hates this code if the parens are not there + int end = (std::max)(0, snapshotLength - 200); + auto liveMessageSearchText = + QString("%1 is live!").arg(this->getDisplayName()); + + for (int i = snapshotLength - 1; i >= end; --i) + { + const auto &s = snapshot[i]; + + if (s->messageText == liveMessageSearchText) + { + s->flags.set(MessageFlag::Disabled); + break; + } + } + } + }; + + this->signalHolder_.managedConnect(this->liveStatusChanged, + onLiveStatusChanged); + // debugging #if 0 for (int i = 0; i < 1000; i++) { @@ -172,7 +252,6 @@ TwitchChannel::~TwitchChannel() void TwitchChannel::initialize() { - this->fetchDisplayName(); this->refreshChatters(); this->refreshBadges(); } @@ -340,6 +419,89 @@ boost::optional TwitchChannel::channelPointReward( return it->second; } +void TwitchChannel::updateStreamStatus( + const std::optional &helixStream) +{ + if (helixStream) + { + auto stream = *helixStream; + { + auto status = this->streamStatus_.access(); + status->viewerCount = stream.viewerCount; + status->gameId = stream.gameId; + status->game = stream.gameName; + status->title = stream.title; + QDateTime since = + QDateTime::fromString(stream.startedAt, Qt::ISODate); + auto diff = since.secsTo(QDateTime::currentDateTime()); + status->uptime = QString::number(diff / 3600) + "h " + + QString::number(diff % 3600 / 60) + "m"; + + status->rerun = false; + status->streamType = stream.type; + } + if (this->setLive(true)) + { + this->liveStatusChanged.invoke(true); + } + this->streamStatusChanged.invoke(); + } + else + { + if (this->setLive(false)) + { + this->liveStatusChanged.invoke(false); + this->streamStatusChanged.invoke(); + } + } +} + +void TwitchChannel::updateStreamTitle(const QString &title) +{ + { + auto status = this->streamStatus_.access(); + if (status->title == title) + { + // Title has not changed + return; + } + status->title = title; + } + this->streamStatusChanged.invoke(); +} + +void TwitchChannel::updateDisplayName(const QString &displayName) +{ + if (displayName == this->getDisplayName()) + { + // Display name has not changed + return; + } + + // Display name has changed + + if (QString::compare(displayName, this->getName(), Qt::CaseInsensitive) == + 0) + { + // Display name is only a case variation of the login name + this->setDisplayName(displayName); + + this->setLocalizedName(displayName); + } + else + { + // Display name contains Chinese, Japanese, or Korean characters + this->setDisplayName(this->getName()); + + this->setLocalizedName( + QString("%1(%2)").arg(this->getName()).arg(displayName)); + } + + this->addRecentChatter(this->getDisplayName()); + + this->displayNameChanged.invoke(); +} + void TwitchChannel::showLoginMessage() { const auto linkColor = MessageColor(MessageColor::Link); @@ -909,183 +1071,16 @@ int TwitchChannel::chatterCount() return this->chatterCount_; } -void TwitchChannel::setLive(bool newLiveStatus) +bool TwitchChannel::setLive(bool newLiveStatus) { + auto guard = this->streamStatus_.access(); + if (guard->live == newLiveStatus) { - auto guard = this->streamStatus_.access(); - if (guard->live == newLiveStatus) - { - return; - } - guard->live = newLiveStatus; + return false; } + guard->live = newLiveStatus; - if (newLiveStatus) - { - if (getApp()->notifications->isChannelNotified(this->getName(), - Platform::Twitch)) - { - if (Toasts::isEnabled()) - { - getApp()->toasts->sendChannelNotification( - this->getName(), this->accessStreamStatus()->title, - Platform::Twitch); - } - if (getSettings()->notificationPlaySound) - { - getApp()->notifications->playSound(); - } - if (getSettings()->notificationFlashTaskbar) - { - getApp()->windows->sendAlert(); - } - } - // Channel live message - MessageBuilder builder; - TwitchMessageBuilder::liveSystemMessage(this->getDisplayName(), - &builder); - this->addMessage(builder.release()); - - // Message in /live channel - MessageBuilder builder2; - TwitchMessageBuilder::liveMessage(this->getDisplayName(), &builder2); - getApp()->twitch->liveChannel->addMessage(builder2.release()); - - // Notify on all channels with a ping sound - if (getSettings()->notificationOnAnyChannel && - !(isInStreamerMode() && - getSettings()->streamerModeSuppressLiveNotifications)) - { - getApp()->notifications->playSound(); - } - } - else - { - // Channel offline message - MessageBuilder builder; - TwitchMessageBuilder::offlineSystemMessage(this->getDisplayName(), - &builder); - this->addMessage(builder.release()); - - // "delete" old 'CHANNEL is live' message - LimitedQueueSnapshot snapshot = - getApp()->twitch->liveChannel->getMessageSnapshot(); - int snapshotLength = snapshot.size(); - - // MSVC hates this code if the parens are not there - int end = (std::max)(0, snapshotLength - 200); - auto liveMessageSearchText = - QString("%1 is live!").arg(this->getDisplayName()); - - for (int i = snapshotLength - 1; i >= end; --i) - { - auto &s = snapshot[i]; - - if (s->messageText == liveMessageSearchText) - { - s->flags.set(MessageFlag::Disabled); - break; - } - } - } - - this->liveStatusChanged.invoke(); -} - -void TwitchChannel::refreshTitle() -{ - // timer has never started, proceed and start it - if (!this->titleRefreshedTimer_.isValid()) - { - this->titleRefreshedTimer_.start(); - } - else if (this->roomId().isEmpty() || - this->titleRefreshedTimer_.elapsed() < TITLE_REFRESH_PERIOD) - { - return; - } - this->titleRefreshedTimer_.restart(); - - getHelix()->getChannel( - this->roomId(), - [this, weak = weakOf(this)](HelixChannel channel) { - ChannelPtr shared = weak.lock(); - - if (!shared) - { - return; - } - - { - auto status = this->streamStatus_.access(); - status->title = channel.title; - } - - this->liveStatusChanged.invoke(); - }, - [] { - // failure - }); -} - -void TwitchChannel::refreshLiveStatus() -{ - auto roomID = this->roomId(); - - if (roomID.isEmpty()) - { - qCDebug(chatterinoTwitch) << "[TwitchChannel" << this->getName() - << "] Refreshing live status (Missing ID)"; - this->setLive(false); - return; - } - - getHelix()->getStreamById( - roomID, - [this, weak = weakOf(this)](bool live, const auto &stream) { - ChannelPtr shared = weak.lock(); - if (!shared) - { - return; - } - - this->parseLiveStatus(live, stream); - }, - [] { - // failure - }, - [] { - // finally - }); -} - -void TwitchChannel::parseLiveStatus(bool live, const HelixStream &stream) -{ - if (!live) - { - this->setLive(false); - return; - } - - { - auto status = this->streamStatus_.access(); - status->viewerCount = stream.viewerCount; - status->gameId = stream.gameId; - status->game = stream.gameName; - status->title = stream.title; - QDateTime since = QDateTime::fromString(stream.startedAt, Qt::ISODate); - auto diff = since.secsTo(QDateTime::currentDateTime()); - status->uptime = QString::number(diff / 3600) + "h " + - QString::number(diff % 3600 / 60) + "m"; - - status->rerun = false; - status->streamType = stream.type; - } - - this->setLive(true); - - // Signal all listeners that the stream status has been updated - this->liveStatusChanged.invoke(); + return true; } void TwitchChannel::loadRecentMessages() @@ -1220,33 +1215,6 @@ void TwitchChannel::refreshChatters() [](auto error, auto message) {}); } -void TwitchChannel::fetchDisplayName() -{ - getHelix()->getUserByName( - this->getName(), - [weak = weakOf(this)](const auto &user) { - auto shared = weak.lock(); - if (!shared) - return; - auto channel = static_cast(shared.get()); - if (QString::compare(user.displayName, channel->getName(), - Qt::CaseInsensitive) == 0) - { - channel->setDisplayName(user.displayName); - channel->setLocalizedName(user.displayName); - } - else - { - channel->setLocalizedName(QString("%1(%2)") - .arg(channel->getName()) - .arg(user.displayName)); - } - channel->addRecentChatter(channel->getDisplayName()); - channel->displayNameChanged.invoke(); - }, - [] {}); -} - void TwitchChannel::addReplyThread(const std::shared_ptr &thread) { this->threads_[thread->rootId()] = thread; diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 746b54ed6..6f9fd6a96 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -18,6 +18,7 @@ #include #include +#include #include namespace chatterino { @@ -120,7 +121,6 @@ public: virtual bool hasHighRateLimit() const override; virtual bool canReconnect() const override; virtual void reconnect() override; - void refreshTitle(); void createClip(); // Data @@ -194,7 +194,23 @@ public: // Signals pajlada::Signals::NoArgSignal roomIdChanged; pajlada::Signals::NoArgSignal userStateChanged; - pajlada::Signals::NoArgSignal liveStatusChanged; + + /** + * This signals fires whenever the live status is changed + * + * Streams are counted as offline by default, so if a stream does not go online + * this signal will never fire + **/ + pajlada::Signals::Signal liveStatusChanged; + + /** + * This signal fires whenever the stream status is changed + * + * This includes when the stream goes from offline to online, + * or the viewer count changes, or the title has been updated + **/ + pajlada::Signals::NoArgSignal streamStatusChanged; + pajlada::Signals::NoArgSignal roomModesChanged; // Channel point rewards @@ -205,6 +221,12 @@ public: boost::optional channelPointReward( const QString &rewardId) const; + // Live status + void updateStreamStatus(const std::optional &helixStream); + void updateStreamTitle(const QString &title); + + void updateDisplayName(const QString &displayName); + private: struct NameOptions { QString displayName; @@ -213,21 +235,23 @@ private: private: // Methods - void refreshLiveStatus(); - void parseLiveStatus(bool live, const HelixStream &stream); void refreshPubSub(); void refreshChatters(); void refreshBadges(); void refreshCheerEmotes(); void loadRecentMessages(); void loadRecentMessagesReconnect(); - void fetchDisplayName(); void cleanUpReplyThreads(); void showLoginMessage(); /** Joins (subscribes to) a Twitch channel for updates on BTTV. */ void joinBttvChannel() const; - void setLive(bool newLiveStatus); + /** + * @brief Sets the live status of this Twitch channel + * + * Returns true if the live status changed with this call + **/ + bool setLive(bool newLiveStatus); void setMod(bool value); void setVIP(bool value); void setStaff(bool value); @@ -236,7 +260,16 @@ private: void setDisplayName(const QString &name); void setLocalizedName(const QString &name); + /** + * Returns the display name of the user + * + * If the display name contained chinese, japenese, or korean characters, the user's login name is returned instead + **/ const QString &getDisplayName() const override; + + /** + * Returns the localized name of the user + **/ const QString &getLocalizedName() const override; QString prepareMessage(const QString &message) const; diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index b7cc4797b..bcefb58dd 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -80,12 +80,6 @@ void TwitchIrcServer::initialize(Settings &settings, Paths &paths) this->reloadBTTVGlobalEmotes(); this->reloadFFZGlobalEmotes(); this->reloadSevenTVGlobalEmotes(); - - /* Refresh all twitch channel's live status in bulk every 30 seconds after starting chatterino */ - QObject::connect(&this->bulkLiveStatusTimer_, &QTimer::timeout, [this] { - this->bulkRefreshLiveStatus(); - }); - this->bulkLiveStatusTimer_.start(30 * 1000); } void TwitchIrcServer::initializeConnection(IrcConnection *connection, @@ -412,59 +406,6 @@ std::shared_ptr TwitchIrcServer::getChannelOrEmptyByID( return Channel::getEmpty(); } -void TwitchIrcServer::bulkRefreshLiveStatus() -{ - auto twitchChans = std::make_shared>(); - - this->forEachChannel([twitchChans](ChannelPtr chan) { - auto tc = dynamic_cast(chan.get()); - if (tc && !tc->roomId().isEmpty()) - { - twitchChans->insert(tc->roomId(), tc); - } - }); - - // iterate over batches of channel IDs - for (const auto &batch : splitListIntoBatches(twitchChans->keys())) - { - getHelix()->fetchStreams( - batch, {}, - [twitchChans](std::vector streams) { - for (const auto &stream : streams) - { - // remaining channels will be used later to set their stream status as offline - // so we use take(id) to remove it - auto tc = twitchChans->take(stream.userId); - if (tc == nullptr) - { - continue; - } - - tc->parseLiveStatus(true, stream); - } - }, - []() { - // failure - }, - [batch, twitchChans] { - // All the channels that were not present in fetchStreams response should be assumed to be offline - // It is necessary to update their stream status in case they've gone live -> offline - // Otherwise some of them will be marked as live forever - for (const auto &chID : batch) - { - auto tc = twitchChans->value(chID); - // early out in case channel does not exist anymore - if (tc == nullptr) - { - continue; - } - - tc->parseLiveStatus(false, {}); - } - }); - } -} - QString TwitchIrcServer::cleanChannelName(const QString &dirtyChannelName) { if (dirtyChannelName.startsWith('#')) diff --git a/src/providers/twitch/TwitchIrcServer.hpp b/src/providers/twitch/TwitchIrcServer.hpp index 4593c48ba..d60554fa3 100644 --- a/src/providers/twitch/TwitchIrcServer.hpp +++ b/src/providers/twitch/TwitchIrcServer.hpp @@ -49,8 +49,6 @@ public: std::shared_ptr getChannelOrEmptyByID(const QString &channelID); - void bulkRefreshLiveStatus(); - void reloadBTTVGlobalEmotes(); void reloadAllBTTVChannelEmotes(); void reloadFFZGlobalEmotes(); @@ -124,7 +122,6 @@ private: BttvEmotes bttv; FfzEmotes ffz; SeventvEmotes seventv_; - QTimer bulkLiveStatusTimer_; pajlada::Signals::SignalHolder signalHolder_; }; diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 04cf34f5d..3c23fcd7f 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -417,6 +417,45 @@ void Helix::createClip(QString channelId, .execute(); } +void Helix::fetchChannels( + QStringList userIDs, + ResultCallback> successCallback, + HelixFailureCallback failureCallback) +{ + QUrlQuery urlQuery; + + for (const auto &userID : userIDs) + { + urlQuery.addQueryItem("broadcaster_id", userID); + } + + this->makeGet("channels", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + auto root = result.parseJson(); + auto data = root.value("data"); + + if (!data.isArray()) + { + failureCallback(); + return Failure; + } + + std::vector channels; + + for (const auto &unparsedChannel : data.toArray()) + { + channels.emplace_back(unparsedChannel.toObject()); + } + + successCallback(channels); + return Success; + }) + .onError([failureCallback](auto /*result*/) { + failureCallback(); + }) + .execute(); +} + void Helix::getChannel(QString broadcasterId, ResultCallback successCallback, HelixFailureCallback failureCallback) diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index 79ff59d6c..6b3230fa8 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -788,6 +788,12 @@ public: std::function failureCallback, std::function finallyCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#get-channel-information + virtual void fetchChannels( + QStringList userIDs, + ResultCallback> successCallback, + HelixFailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#get-channel-information virtual void getChannel(QString broadcasterId, ResultCallback successCallback, @@ -1101,6 +1107,12 @@ public: std::function failureCallback, std::function finallyCallback) final; + // https://dev.twitch.tv/docs/api/reference#get-channel-information + void fetchChannels( + QStringList userIDs, + ResultCallback> successCallback, + HelixFailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#get-channel-information void getChannel(QString broadcasterId, ResultCallback successCallback, diff --git a/src/providers/twitch/api/README.md b/src/providers/twitch/api/README.md index b031adea6..e300e5440 100644 --- a/src/providers/twitch/api/README.md +++ b/src/providers/twitch/api/README.md @@ -43,7 +43,7 @@ URL: https://dev.twitch.tv/docs/api/reference#get-streams Used in: -- `TwitchChannel` to get live status, game, title, and viewer count of a channel +- `LiveController` to get live status, game, title, and viewer count of a channel - `NotificationController` to provide notifications for channels you might not have open in Chatterino, but are still interested in getting notifications for ### Create Clip @@ -61,7 +61,7 @@ URL: https://dev.twitch.tv/docs/api/reference#get-channel-information Used in: -- `TwitchChannel` to refresh stream title +- `LiveController` to refresh stream title & display name ### Update Channel diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index 94e372f65..0e86c815c 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -800,7 +800,7 @@ void ChannelView::setChannel(ChannelPtr underlyingChannel) if (auto tc = dynamic_cast(underlyingChannel.get())) { this->channelConnections_.managedConnect( - tc->liveStatusChanged, [this]() { + tc->streamStatusChanged, [this]() { this->liveStatusChanged.invoke(); }); } diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp index 412c6f798..703b248a3 100644 --- a/src/widgets/splits/SplitHeader.cpp +++ b/src/widgets/splits/SplitHeader.cpp @@ -740,7 +740,7 @@ void SplitHeader::handleChannelChanged() if (auto *twitchChannel = dynamic_cast(channel.get())) { this->channelConnections_.managedConnect( - twitchChannel->liveStatusChanged, [this]() { + twitchChannel->streamStatusChanged, [this]() { this->updateChannelText(); }); } @@ -956,10 +956,6 @@ void SplitHeader::enterEvent(QEvent *event) if (!this->tooltipText_.isEmpty()) { auto *channel = this->split_->getChannel().get(); - if (channel->getType() == Channel::Type::Twitch) - { - dynamic_cast(channel)->refreshTitle(); - } auto *tooltip = TooltipWidget::instance(); tooltip->setOne({nullptr, this->tooltipText_});