From d6d2966696cbf33be5ead49544d850ebace2996b Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Fri, 10 Jan 2025 19:29:00 -0500 Subject: [PATCH] Refactor for flow (#5281) * Revert "New RTMP target. (#5275)" This reverts commit b14a44888434344ea876f5cdb663cf80519f6cd0. * Added new target. * Add test and error handling. * WIP: title banner * Go live polishes. * Fix broadcast id and test. --- app/app-services.ts | 6 +- .../pages/onboarding/Connect.tsx | 10 +- .../onboarding/PrimaryPlatformSelect.tsx | 7 +- app/components-react/root/LiveDock.tsx | 37 +-- app/components-react/sidebar/NavTools.tsx | 3 +- .../sidebar/PlatformIndicator.m.less | 7 +- .../sidebar/PlatformIndicator.tsx | 3 +- .../windows/go-live/CommonPlatformFields.tsx | 19 +- .../windows/go-live/GoLiveChecklist.m.less | 1 + .../go-live/platforms/KickEditStreamInfo.tsx | 79 ++--- .../windows/settings/Stream.tsx | 75 +---- app/components/shared/PlatformLogo.m.less | 9 +- app/components/shared/PlatformLogo.tsx | 2 +- app/i18n/en-US/kick.json | 6 +- app/services/platforms/index.ts | 4 +- app/services/platforms/kick.ts | 301 ++++++++++++++---- app/services/restream.ts | 6 +- app/services/streaming/streaming-api.ts | 8 +- app/services/streaming/streaming.ts | 6 +- app/services/user/index.ts | 33 +- app/styles/colors.less | 2 +- test/data/dummy-accounts.ts | 24 +- test/regular/streaming/kick.ts | 32 ++ 23 files changed, 401 insertions(+), 279 deletions(-) create mode 100644 test/regular/streaming/kick.ts diff --git a/app/app-services.ts b/app/app-services.ts index df041f8914af..5f9777cd7560 100644 --- a/app/app-services.ts +++ b/app/app-services.ts @@ -74,11 +74,11 @@ export { export { FacebookService } from 'services/platforms/facebook'; export { TikTokService } from 'services/platforms/tiktok'; export { TrovoService } from 'services/platforms/trovo'; +export { KickService } from 'services/platforms/kick'; export { RestreamService } from 'services/restream'; export { TwitterService } from 'services/integrations/twitter'; export { TwitterPlatformService } from 'services/platforms/twitter'; export { InstagramService } from 'services/platforms/instagram'; -export { KickService } from 'services/platforms/kick'; export { UsageStatisticsService } from './services/usage-statistics'; export { GameOverlayService } from 'services/game-overlay'; export { SharedStorageService } from 'services/integrations/shared-storage'; @@ -206,10 +206,10 @@ import { MarkersService } from 'services/markers'; import { SharedStorageService } from 'services/integrations/shared-storage'; import { RealmService } from 'services/realm'; import { InstagramService } from 'services/platforms/instagram'; -import { KickService } from 'services/platforms/kick'; import { TwitchStudioImporterService } from 'services/ts-importer'; import { RemoteControlService } from 'services/api/remote-control-api'; import { UrlService } from 'services/hosts'; +import { KickService } from 'services/platforms/kick'; export const AppServices = { AppService, @@ -242,8 +242,8 @@ export const AppServices = { TwitchTagsService, TwitchContentClassificationService, TrovoService, - InstagramService, KickService, + InstagramService, DismissablesService, HighlighterService, GrowService, diff --git a/app/components-react/pages/onboarding/Connect.tsx b/app/components-react/pages/onboarding/Connect.tsx index d70690d6d088..f65c62f050ba 100644 --- a/app/components-react/pages/onboarding/Connect.tsx +++ b/app/components-react/pages/onboarding/Connect.tsx @@ -63,7 +63,7 @@ export function Connect() { // streamlabs and trovo are added separarely on markup below const platforms = RecordingModeService.views.isRecordingModeEnabled ? ['youtube'] - : ['twitch', 'youtube', 'facebook', 'twitter', 'tiktok']; + : ['twitch', 'youtube', 'tiktok', 'kick', 'facebook', 'twitter']; const shouldAddTrovo = !RecordingModeService.views.isRecordingModeEnabled; @@ -132,7 +132,9 @@ export function Connect() { loading={loading} onClick={() => authPlatform(platform, afterLogin)} key={platform} - logoSize={['twitter', 'tiktok', 'youtube'].includes(platform) ? 15 : undefined} + logoSize={ + ['twitter', 'tiktok', 'youtube', 'kick'].includes(platform) ? 15 : undefined + } > %{platform}', { @@ -263,7 +265,9 @@ export class LoginModule { const result = await this.UserService.startAuth( platform, - ['youtube', 'twitch', 'twitter', 'tiktok'].includes(platform) ? 'external' : 'internal', + ['youtube', 'twitch', 'twitter', 'tiktok', 'kick'].includes(platform) + ? 'external' + : 'internal', merge, ); diff --git a/app/components-react/pages/onboarding/PrimaryPlatformSelect.tsx b/app/components-react/pages/onboarding/PrimaryPlatformSelect.tsx index c6fb8d9a1451..37ea80031882 100644 --- a/app/components-react/pages/onboarding/PrimaryPlatformSelect.tsx +++ b/app/components-react/pages/onboarding/PrimaryPlatformSelect.tsx @@ -22,7 +22,7 @@ export function PrimaryPlatformSelect() { isPrime: UserService.state.isPrime, })); const { loading, authInProgress, authPlatform, finishSLAuth } = useModule(LoginModule); - const platforms = ['twitch', 'youtube', 'facebook', 'twitter', 'tiktok', 'trovo']; + const platforms = ['twitch', 'youtube', 'tiktok', 'kick', 'facebook', 'twitter', 'trovo']; const platformOptions = [ { value: 'twitch', @@ -54,6 +54,11 @@ export function PrimaryPlatformSelect() { label: 'TikTok', image: , }, + { + value: 'kick', + label: 'Kick', + image: , + }, ].filter(opt => { return linkedPlatforms.includes(opt.value as TPlatform); }); diff --git a/app/components-react/root/LiveDock.tsx b/app/components-react/root/LiveDock.tsx index 41da076a33e4..278d5a82e618 100644 --- a/app/components-react/root/LiveDock.tsx +++ b/app/components-react/root/LiveDock.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import * as remote from '@electron/remote'; import cx from 'classnames'; import Animation from 'rc-animate'; -import { Button, Menu } from 'antd'; +import { Menu } from 'antd'; import pick from 'lodash/pick'; import { initStore, useController } from 'components-react/hooks/zustand'; import { EStreamingState } from 'services/streaming'; @@ -27,6 +27,7 @@ class LiveDockController { private youtubeService = Services.YoutubeService; private facebookService = Services.FacebookService; private trovoService = Services.TrovoService; + private kickService = Services.KickService; private tiktokService = Services.TikTokService; private userService = Services.UserService; private customizationService = Services.CustomizationService; @@ -159,6 +160,7 @@ class LiveDockController { // Twitter & Tiktok don't support editing title after going live if (this.isPlatform('twitter') && !this.isRestreaming) return false; if (this.isPlatform('tiktok') && !this.isRestreaming) return false; + if (this.isPlatform('kick') && !this.isRestreaming) return false; return ( this.streamingService.views.isMidStreamMode || @@ -181,6 +183,7 @@ class LiveDockController { if (this.platform === 'youtube') url = this.youtubeService.streamPageUrl; if (this.platform === 'facebook') url = this.facebookService.streamPageUrl; if (this.platform === 'trovo') url = this.trovoService.streamPageUrl; + if (this.platform === 'kick') url = this.kickService.streamPageUrl; if (this.platform === 'tiktok') url = this.tiktokService.streamPageUrl; remote.shell.openExternal(url); } @@ -190,6 +193,7 @@ class LiveDockController { if (this.platform === 'youtube') url = this.youtubeService.dashboardUrl; if (this.platform === 'facebook') url = this.facebookService.streamDashboardUrl; if (this.platform === 'tiktok') url = this.tiktokService.dashboardUrl; + if (this.platform === 'kick') url = this.kickService.dashboardUrl; remote.shell.openExternal(url); } @@ -356,33 +360,6 @@ function LiveDock(p: { onLeft: boolean }) { return <>; } - const showKickInfo = - visibleChat === 'kick' || (visibleChat === 'default' && primaryChat === 'kick'); - if (showKickInfo) { - return ( -
-
- {$t('Access chat for Kick in the Stream Dashboard.')} -
- -
- ); - } - return ( ctrl.showEditStreamInfo()} className="icon-edit" /> )} - {isPlatform(['youtube', 'facebook', 'trovo', 'tiktok']) && isStreaming && ( + {isPlatform(['youtube', 'facebook', 'trovo', 'tiktok', 'kick']) && isStreaming && (
- {(isPlatform(['twitch', 'trovo', 'facebook']) || + {(isPlatform(['twitch', 'trovo', 'facebook', 'kick']) || (isPlatform(['youtube', 'twitter']) && isStreaming) || (isPlatform(['tiktok']) && isRestreaming)) && ( ctrl.refreshChat()}>{$t('Refresh Chat')} diff --git a/app/components-react/sidebar/NavTools.tsx b/app/components-react/sidebar/NavTools.tsx index 6ef1e8aed826..fefcf0f32f97 100644 --- a/app/components-react/sidebar/NavTools.tsx +++ b/app/components-react/sidebar/NavTools.tsx @@ -80,9 +80,8 @@ export default function SideNav() { const throttledOpenDashboard = throttle(openDashboard, 2000, { trailing: false }); // Instagram doesn't provide a username, since we're not really linked, pass undefined for a generic logout msg w/o it - // Kick doesn't provide a username, since we're not really linked, pass undefined for a generic logout msg w/o it const username = - isLoggedIn && !['instagram', 'kick'].includes(UserService.views.auth!.primaryPlatform) + isLoggedIn && UserService.views.auth!.primaryPlatform !== 'instagram' ? UserService.username : undefined; diff --git a/app/components-react/sidebar/PlatformIndicator.m.less b/app/components-react/sidebar/PlatformIndicator.m.less index a6799b5e3e8d..d7372cb02fae 100644 --- a/app/components-react/sidebar/PlatformIndicator.m.less +++ b/app/components-react/sidebar/PlatformIndicator.m.less @@ -11,7 +11,12 @@ &-facebook { color: var(--facebook) !important; } - &-trovo, &-twitter, &-tiktok, &-instagram, &-youtube, &-kick { + &-trovo, + &-twitter, + &-tiktok, + &-instagram, + &-youtube, + &-kick { width: 15px; height: 15px; } diff --git a/app/components-react/sidebar/PlatformIndicator.tsx b/app/components-react/sidebar/PlatformIndicator.tsx index af10f18e27f9..d44cedfa22f6 100644 --- a/app/components-react/sidebar/PlatformIndicator.tsx +++ b/app/components-react/sidebar/PlatformIndicator.tsx @@ -46,8 +46,7 @@ export default function PlatformIndicator({ platform }: IPlatformIndicatorProps) } const SinglePlatformIndicator = ({ platform }: Pick) => { - const username = - platform?.type === 'instagram' || platform?.type === 'kick' ? undefined : platform?.username; + const username = platform?.type === 'instagram' ? undefined : platform?.username; return ( <> diff --git a/app/components-react/windows/go-live/CommonPlatformFields.tsx b/app/components-react/windows/go-live/CommonPlatformFields.tsx index d76d2d4f014a..56ee9afeee2d 100644 --- a/app/components-react/windows/go-live/CommonPlatformFields.tsx +++ b/app/components-react/windows/go-live/CommonPlatformFields.tsx @@ -1,6 +1,6 @@ import { TPlatform } from '../../../services/platforms'; import { $t } from '../../../services/i18n'; -import React from 'react'; +import React, { useMemo } from 'react'; import { CheckboxInput, InputComponent, TextAreaInput, TextInput } from '../../shared/inputs'; import { assertIsDefined } from '../../../util/properties-type-guards'; import InputWrapper from '../../shared/inputs/InputWrapper'; @@ -90,6 +90,18 @@ export const CommonPlatformFields = InputComponent((rawProps: IProps) => { maxCharacters = 140; } + const titleTooltip = useMemo(() => { + if (enabledPlatforms.includes('tiktok')) { + return $t('Only 32 characters of your title will display on TikTok'); + } + + if (enabledPlatforms.length === 1 && p?.platform === 'kick') { + return $t('Edit your stream title on Kick after going live.'); + } + + return undefined; + }, [enabledPlatforms]); + return (
{/* USE CUSTOM CHECKBOX */} @@ -115,10 +127,7 @@ export const CommonPlatformFields = InputComponent((rawProps: IProps) => { label={$t('Title')} required={true} max={maxCharacters} - tooltip={ - enabledPlatforms.includes('tiktok') && - $t('Only 32 characters of your title will display on TikTok') - } + tooltip={titleTooltip} /> {/*DESCRIPTION*/} diff --git a/app/components-react/windows/go-live/GoLiveChecklist.m.less b/app/components-react/windows/go-live/GoLiveChecklist.m.less index a42fe4763bb0..1efbcd9069e4 100644 --- a/app/components-react/windows/go-live/GoLiveChecklist.m.less +++ b/app/components-react/windows/go-live/GoLiveChecklist.m.less @@ -6,6 +6,7 @@ align-items: center; justify-content: center; height: 100%; + margin: 0px 20px; } // make timeline icons and text bigger diff --git a/app/components-react/windows/go-live/platforms/KickEditStreamInfo.tsx b/app/components-react/windows/go-live/platforms/KickEditStreamInfo.tsx index 58184e7f923b..25a0722b75e4 100644 --- a/app/components-react/windows/go-live/platforms/KickEditStreamInfo.tsx +++ b/app/components-react/windows/go-live/platforms/KickEditStreamInfo.tsx @@ -1,65 +1,36 @@ import React from 'react'; -import { $t } from 'services/i18n'; +import { CommonPlatformFields } from '../CommonPlatformFields'; import Form from '../../../shared/inputs/Form'; -import { createBinding } from '../../../shared/inputs'; -import { IPlatformComponentParams } from './PlatformSettingsLayout'; -import { clipboard } from 'electron'; -import { TextInput } from 'components-react/shared/inputs'; -import { Alert, Button } from 'antd'; +import { createBinding, InputComponent } from '../../../shared/inputs'; +import PlatformSettingsLayout, { IPlatformComponentParams } from './PlatformSettingsLayout'; +import { IKickStartStreamOptions } from '../../../../services/platforms/kick'; -/** - * Note: The implementation for this component is a light refactor of the InstagramEditStreamInfo component. +/*** + * Stream Settings for Kick */ +export const KickEditStreamInfo = InputComponent((p: IPlatformComponentParams<'kick'>) => { + function updateSettings(patch: Partial) { + p.onChange({ ...kickSettings, ...patch }); + } -type Props = IPlatformComponentParams<'kick'> & { - isStreamSettingsWindow?: boolean; -}; - -export function KickEditStreamInfo(p: Props) { - const bind = createBinding(p.value, updatedSettings => - p.onChange({ ...p.value, ...updatedSettings }), - ); - - const { isStreamSettingsWindow } = p; - const streamKeyLabel = $t(isStreamSettingsWindow ? 'Stream Key' : 'Kick Stream Key'); - const streamUrlLabel = $t(isStreamSettingsWindow ? 'Stream URL' : 'Kick Stream URL'); + const kickSettings = p.value; + const bind = createBinding(kickSettings, newKickSettings => updateSettings(newKickSettings)); return (
- } - /> - - } + + } + requiredFields={
} /> - {!isStreamSettingsWindow && ( - - )} ); -} - -function PasteButton({ onPaste }: { onPaste: (text: string) => void }) { - return ( - - ); -} +}); diff --git a/app/components-react/windows/settings/Stream.tsx b/app/components-react/windows/settings/Stream.tsx index 91316fdab51c..67c1fa05e58f 100644 --- a/app/components-react/windows/settings/Stream.tsx +++ b/app/components-react/windows/settings/Stream.tsx @@ -19,8 +19,6 @@ import Translate from 'components-react/shared/Translate'; import * as remote from '@electron/remote'; import { InstagramEditStreamInfo } from '../go-live/platforms/InstagramEditStreamInfo'; import { IInstagramStartStreamOptions } from 'services/platforms/instagram'; -import { KickEditStreamInfo } from '../go-live/platforms/KickEditStreamInfo'; -import { IKickStartStreamOptions } from 'services/platforms/kick'; import { metadata } from 'components-react/shared/inputs/metadata'; import FormFactory, { TInputValue } from 'components-react/shared/inputs/FormFactory'; import { alertAsync } from '../../modals'; @@ -219,7 +217,7 @@ class StreamSettingsModule { } async platformMergeInline(platform: TPlatform) { - const mode = ['youtube', 'twitch', 'twitter', 'tiktok'].includes(platform) + const mode = ['youtube', 'twitch', 'twitter', 'tiktok', 'kick'].includes(platform) ? 'external' : 'internal'; @@ -413,14 +411,13 @@ function SLIDBlock() { */ function Platform(p: { platform: TPlatform }) { const platform = p.platform; - const { UserService, StreamingService, InstagramService, KickService } = Services; + const { UserService, StreamingService, InstagramService } = Services; const { canEditSettings, platformMergeInline, platformUnlink } = useStreamSettings(); - const { isLoading, authInProgress, instagramSettings, kickSettings } = useVuex(() => ({ + const { isLoading, authInProgress, instagramSettings } = useVuex(() => ({ isLoading: UserService.state.authProcessState === EAuthProcessState.Loading, authInProgress: UserService.state.authProcessState === EAuthProcessState.InProgress, instagramSettings: InstagramService.state.settings, - kickSettings: KickService.state.settings, })); const isMerged = StreamingService.views.isPlatformLinked(platform); @@ -439,11 +436,7 @@ function Platform(p: { platform: TPlatform }) { */ const isInstagram = platform === 'instagram'; const [showInstagramFields, setShowInstagramFields] = useState(isInstagram && isMerged); - - const isKick = platform === 'kick'; - const [showKickFields, setShowKickFields] = useState(isKick && isMerged); - - const shouldShowUsername = !isInstagram && !isKick; + const shouldShowUsername = !isInstagram; const usernameOrBlank = shouldShowUsername ? ( <> @@ -473,7 +466,7 @@ function Platform(p: { platform: TPlatform }) { const ConnectButton = () => (
); } - - if (isKick && showKickFields) { - return ( -
- -
- ); - } - return null; }; @@ -583,7 +523,10 @@ function Platform(p: { platform: TPlatform }) {
{shouldShowConnectBtn && } {shouldShowUnlinkBtn && ( - )} diff --git a/app/components/shared/PlatformLogo.m.less b/app/components/shared/PlatformLogo.m.less index b82d7edd2005..57afedab6ace 100644 --- a/app/components/shared/PlatformLogo.m.less +++ b/app/components/shared/PlatformLogo.m.less @@ -36,4 +36,11 @@ background-size: contain; background-repeat: no-repeat; } - +.kick { + background-image: url('https://slobs-cdn.streamlabs.com/media/kick-logo.png'); + display: inline-block; + width: 40px; + height: 40px; + background-size: contain; + background-repeat: no-repeat; +} diff --git a/app/components/shared/PlatformLogo.tsx b/app/components/shared/PlatformLogo.tsx index 8ea14242f941..a7a1bf7e4814 100644 --- a/app/components/shared/PlatformLogo.tsx +++ b/app/components/shared/PlatformLogo.tsx @@ -21,9 +21,9 @@ export default class PlatformLogo extends TsxComponent { nimotv: 'nimotv', streamlabs: 'icon-streamlabs', trovo: 'trovo', + kick: 'kick', twitter: 'twitter', instagram: 'instagram', - kick: 'kick', }[this.props.platform]; } diff --git a/app/i18n/en-US/kick.json b/app/i18n/en-US/kick.json index 14a2a825b5df..37b7dc85eb43 100644 --- a/app/i18n/en-US/kick.json +++ b/app/i18n/en-US/kick.json @@ -1,7 +1,3 @@ { - "Access chat for Kick in the Stream Dashboard.": "Access chat for Kick in the Stream Dashboard.", - "Open Kick Chat": "Open Kick Chat", - "Kick Stream Key": "Kick Stream Key", - "Kick Stream URL": "Kick Stream URL", - "Remember to open Kick in browser and enter your Stream URL and Key to start streaming!": "Remember to open Kick in browser and enter your Stream URL and Key to start streaming!" + "Edit your stream title on Kick after going live.": "Edit your stream title on Kick after going live." } diff --git a/app/services/platforms/index.ts b/app/services/platforms/index.ts index 37dc4e966cad..6040680f4924 100644 --- a/app/services/platforms/index.ts +++ b/app/services/platforms/index.ts @@ -3,7 +3,6 @@ import { IYoutubeStartStreamOptions, YoutubeService } from './youtube'; import { FacebookService, IFacebookStartStreamOptions } from './facebook'; import { ITikTokStartStreamOptions, TikTokService } from './tiktok'; import { InstagramService, IInstagramStartStreamOptions } from './instagram'; -import { KickService, IKickStartStreamOptions } from './kick'; import { TwitterPlatformService } from './twitter'; import { TTwitchOAuthScope } from './twitch/index'; import { IGoLiveSettings } from 'services/streaming'; @@ -11,6 +10,7 @@ import { WidgetType } from '../widgets'; import { ITrovoStartStreamOptions, TrovoService } from './trovo'; import { TDisplayType } from 'services/settings-v2'; import { $t } from 'services/i18n'; +import { KickService, IKickStartStreamOptions } from './kick'; export type Tag = string; export interface IGame { @@ -288,9 +288,9 @@ export function getPlatformService(platform: TPlatform): IPlatformService { facebook: FacebookService.instance, tiktok: TikTokService.instance, trovo: TrovoService.instance, + kick: KickService.instance, twitter: TwitterPlatformService.instance, instagram: InstagramService.instance, - kick: KickService.instance, }[platform]; } diff --git a/app/services/platforms/kick.ts b/app/services/platforms/kick.ts index 4258916daca6..81667f59ad61 100644 --- a/app/services/platforms/kick.ts +++ b/app/services/platforms/kick.ts @@ -1,36 +1,62 @@ -import { getDefined } from 'util/properties-type-guards'; -import { - IPlatformRequest, - IPlatformService, - IPlatformState, - EPlatformCallResult, - TStartStreamOptions, - IGame, - TPlatformCapability, -} from '.'; +import { InheritMutations, Inject, mutation } from '../core'; import { BasePlatformService } from './base-platform'; -import { IGoLiveSettings } from 'services/streaming'; +import { IPlatformRequest, IPlatformService, IPlatformState, TPlatformCapability } from './index'; +import { authorizedHeaders, jfetch } from '../../util/requests'; +import { throwStreamError } from '../streaming/stream-error'; +import { platformAuthorizedRequest } from './utils'; +import { IGoLiveSettings } from '../streaming'; +import { TOutputOrientation } from 'services/restream'; +import { IVideo } from 'obs-studio-node'; import { TDisplayType } from 'services/settings-v2'; -import { InheritMutations } from 'services/core'; -import { WidgetType } from 'services/widgets'; +import { I18nService } from 'services/i18n'; +import { getDefined } from 'util/properties-type-guards'; +import { WindowsService } from 'services/windows'; +import { DiagnosticsService } from 'services/diagnostics'; -/** - * Note: The implementation for this service is a light refactor of the Instagram service. - */ +interface IKickStartStreamResponse { + id?: string; + key: string; + rtmp: string; + chat_url: string; + broadcast_id?: string | null; + channel_name: string; + platform_id: string; + region?: string; + chat_id?: string; +} +interface IKickEndStreamResponse { + id: string; +} + +interface IKickError { + success: boolean; + error: boolean; + message: string; + data: any[]; +} interface IKickServiceState extends IPlatformState { settings: IKickStartStreamSettings; + ingest: string; + chatUrl: string; + channelName: string; + platformId?: string; } interface IKickStartStreamSettings { title: string; - streamUrl: string; - streamKey: string; + display: TDisplayType; + video?: IVideo; + mode?: TOutputOrientation; } export interface IKickStartStreamOptions { title: string; - streamUrl: string; - streamKey: string; +} + +interface IKickRequestHeaders extends Dictionary { + Accept: string; + 'Content-Type': string; + Authorization: string; } @InheritMutations() @@ -39,53 +65,165 @@ export class KickService implements IPlatformService { static initialState: IKickServiceState = { ...BasePlatformService.initialState, - settings: { title: '', streamUrl: '', streamKey: '' }, + settings: { + title: '', + display: 'horizontal', + mode: 'landscape', + }, + ingest: '', + chatUrl: '', + channelName: '', }; - searchGames?: (searchString: string) => Promise; - scheduleStream?: (startTime: number, info: TStartStreamOptions) => Promise; - getHeaders: (req: IPlatformRequest, useToken?: string | boolean) => Dictionary; - streamPageUrl: string; - widgetsWhitelist?: WidgetType[]; + @Inject() windowsService: WindowsService; + @Inject() diagnosticsService: DiagnosticsService; readonly apiBase = ''; + readonly domain = 'https://kick.com'; readonly platform = 'kick'; readonly displayName = 'Kick'; - readonly capabilities = new Set(['resolutionPreset']); + readonly capabilities = new Set(['chat']); - readonly authWindowOptions = {}; - readonly authUrl = ''; + authWindowOptions: Electron.BrowserWindowConstructorOptions = { + width: 600, + height: 800, + }; - fetchNewToken() { - return Promise.resolve(); + private get oauthToken() { + return this.userService.views.state.auth?.platforms?.kick?.token; } - protected init() { - this.syncSettingsWithLocalStorage(); + async beforeGoLive(goLiveSettings: IGoLiveSettings, display?: TDisplayType) { + const kickSettings = getDefined(goLiveSettings.platforms.kick); + const context = display ?? kickSettings?.display; - this.userService.userLogout.subscribe(() => { - this.updateSettings({ title: this.state.settings.title, streamUrl: '', streamKey: '' }); - }); + try { + const streamInfo = await this.startStream( + goLiveSettings.platforms.kick ?? this.state.settings, + ); + + this.SET_INGEST(streamInfo.rtmp); + this.SET_STREAM_KEY(streamInfo.key); + this.SET_CHAT_URL(streamInfo.chat_url); + this.SET_PLATFORM_ID(streamInfo.platform_id); + + if (!this.streamingService.views.isMultiplatformMode) { + this.streamSettingsService.setSettings( + { + streamType: 'rtmp_custom', + key: streamInfo.key, + server: streamInfo.rtmp, + }, + context, + ); + } + + await this.putChannelInfo(kickSettings); + this.setPlatformContext('kick'); + } catch (e: unknown) { + console.error('Error starting stream: ', e); + throwStreamError('PLATFORM_REQUEST_FAILED', e as any); + } + } + + async afterStopStream(): Promise { + // clear server url and stream key + this.SET_INGEST(''); + this.SET_STREAM_KEY(''); } - async beforeGoLive(goLiveSettings: IGoLiveSettings, context?: TDisplayType) { - const settings = getDefined(goLiveSettings.platforms.kick); + // Note, this needs to be here but should never be called, because we + // currently don't make any calls directly to Kick + async fetchNewToken(): Promise { + const host = this.hostsService.streamlabs; + const url = `https://${host}/api/v5/slobs/kick/refresh`; + const headers = authorizedHeaders(this.userService.apiToken!); + const request = new Request(url, { headers }); - if (!this.streamingService.views.isMultiplatformMode) { - this.streamSettingsService.setSettings( - { - streamType: 'rtmp_custom', - key: settings.streamKey, - server: settings.streamUrl, - }, - context, - ); + return jfetch<{ access_token: string }>(request) + .then(response => { + return this.userService.updatePlatformToken('kick', response.access_token); + }) + .catch(e => { + console.error('Error fetching new token.'); + return Promise.reject(e); + }); + } + + /** + * Request Kick API and wrap failed response to a unified error model + */ + async requestKick(reqInfo: IPlatformRequest | string): Promise { + try { + return await platformAuthorizedRequest('kick', reqInfo); + } catch (e: unknown) { + const code = (e as any).result?.error?.code; + + const details = (e as any).result?.error + ? `${(e as any).result.error.type} ${(e as any).result.error.message}` + : 'Connection failed'; + + console.error('Error fetching Kick API: ', details, code); + + return Promise.reject(e); } + } - this.SET_STREAM_KEY(settings.streamKey); - this.UPDATE_STREAM_SETTINGS(settings); - this.setPlatformContext('kick'); + /** + * Starts the stream + * @remark If a user is live and attempts to go live via another + * another streaming method such as Kick's app, this stream will continue + * and the other stream will be prevented from going live. If another instance + * of Streamlabs attempts to go live to Kick, the first stream will be ended + * and Desktop will enter a reconnecting state, which eventually times out. + */ + async startStream(opts: IKickStartStreamOptions): Promise { + const host = this.hostsService.streamlabs; + const url = `https://${host}/api/v5/slobs/kick/stream/start`; + const headers = authorizedHeaders(this.userService.apiToken!); + + const body = new FormData(); + body.append('title', opts.title); + + const request = new Request(url, { headers, method: 'POST', body }); + + return jfetch(request).catch((e: IKickError | unknown) => { + console.error('Error starting Kick stream: ', e); + + const defaultError = { + status: 403, + statusText: 'Unable to start Kick stream.', + }; + + if (!e) throwStreamError('PLATFORM_REQUEST_FAILED', defaultError); + + // check if the error is an IKickError + if (typeof e === 'object' && e.hasOwnProperty('success')) { + const error = e as IKickError; + throwStreamError( + 'PLATFORM_REQUEST_FAILED', + { + ...error, + status: 403, + statusText: error.message, + }, + defaultError.statusText, + ); + } + + throwStreamError('PLATFORM_REQUEST_FAILED', e as any, defaultError.statusText); + }); + } + + async endStream(id: string) { + const host = this.hostsService.streamlabs; + const url = `https://${host}/api/v5/slobs/kick/stream/${id}/end`; + const headers = authorizedHeaders(this.userService.apiToken!); + const request = new Request(url, { headers, method: 'POST' }); + + return jfetch(request); } + /** * prepopulate channel info and save it to the store */ @@ -93,28 +231,27 @@ export class KickService this.SET_PREPOPULATED(true); } - async validatePlatform() { - /* - * TODO: this validation isn't needed, but doesn't hurt to be safe, in case we decide to persist - * stream URL as part of "linking". Maybe also validate stream keys, they seem to start with IG-* - */ - if (!this.state.settings.streamKey.length || !this.state.settings.streamUrl.length) { - return EPlatformCallResult.Error; - } - - return EPlatformCallResult.Success; + async putChannelInfo(settings: IKickStartStreamOptions): Promise { + this.SET_STREAM_SETTINGS(settings); } - async putChannelInfo(): Promise { - // N/A + getHeaders(req: IPlatformRequest, useToken?: string | boolean): IKickRequestHeaders { + return { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.oauthToken}`, + }; } - updateSettings(settings: IKickStartStreamOptions) { - this.UPDATE_STREAM_SETTINGS(settings); + get authUrl() { + const host = this.hostsService.streamlabs; + const query = `_=${Date.now()}&skip_splash=true&external=electron&kick&force_verify&origin=slobs`; + return `https://${host}/slobs/login?${query}`; } - unlink() { - this.userService.UNLINK_PLATFORM('kick'); + get mergeUrl(): string { + const host = this.hostsService.streamlabs; + return `https://${host}/dashboard#/settings/account-settings/platforms`; } get liveDockEnabled(): boolean { @@ -122,6 +259,36 @@ export class KickService } get chatUrl(): string { - return 'https://dashboard.kick.com/stream'; + return this.state.chatUrl; + } + + get dashboardUrl(): string { + return `https://dashboard.${this.domain.split('//')[1]}/stream`; + } + + get streamPageUrl(): string { + const username = this.userService.state.auth?.platforms?.kick?.username; + if (!username) return ''; + + return `${this.domain}/${username}`; + } + + get locale(): string { + return I18nService.instance.state.locale; + } + + @mutation() + SET_INGEST(ingest: string) { + this.state.ingest = ingest; + } + + @mutation() + SET_CHAT_URL(chatUrl: string) { + this.state.chatUrl = chatUrl; + } + + @mutation() + SET_PLATFORM_ID(platformId: string) { + this.state.platformId = platformId; } } diff --git a/app/services/restream.ts b/app/services/restream.ts index 3ae9f5ab7a15..aeaf4024f761 100644 --- a/app/services/restream.ts +++ b/app/services/restream.ts @@ -56,8 +56,8 @@ export class RestreamService extends StatefulService { @Inject() facebookService: FacebookService; @Inject('TikTokService') tiktokService: TikTokService; @Inject() trovoService: TrovoService; - @Inject() instagramService: InstagramService; @Inject() kickService: KickService; + @Inject() instagramService: InstagramService; @Inject() videoSettingsService: VideoSettingsService; @Inject() dualOutputService: DualOutputService; @Inject('TwitterPlatformService') twitterService: TwitterPlatformService; @@ -311,7 +311,7 @@ export class RestreamService extends StatefulService { // treat instagram as a custom destination const instagramTarget = newTargets.find(t => t.platform === 'instagram'); if (instagramTarget) { - instagramTarget.platform = 'relay' as 'relay'; + instagramTarget.platform = 'relay'; instagramTarget.streamKey = `${this.instagramService.state.settings.streamUrl}${this.instagramService.state.streamKey}`; instagramTarget.mode = isDualOutputMode ? this.dualOutputService.views.getPlatformMode('instagram') @@ -322,7 +322,7 @@ export class RestreamService extends StatefulService { const kickTarget = newTargets.find(t => t.platform === 'kick'); if (kickTarget) { kickTarget.platform = 'relay'; - kickTarget.streamKey = `${this.kickService.state.settings.streamUrl}/${this.kickService.state.settings.streamKey}`; + kickTarget.streamKey = `${this.kickService.state.ingest}/${this.kickService.state.streamKey}`; kickTarget.mode = isDualOutputMode ? this.dualOutputService.views.getPlatformMode('kick') : 'landscape'; diff --git a/app/services/streaming/streaming-api.ts b/app/services/streaming/streaming-api.ts index a7fc92c22acb..9baec256eb06 100644 --- a/app/services/streaming/streaming-api.ts +++ b/app/services/streaming/streaming-api.ts @@ -7,10 +7,10 @@ import { IStreamError } from './stream-error'; import { ICustomStreamDestination } from '../settings/streaming'; import { ITikTokStartStreamOptions } from '../platforms/tiktok'; import { ITrovoStartStreamOptions } from '../platforms/trovo'; -import { IVideo } from 'obs-studio-node'; +import { IKickStartStreamOptions } from 'services/platforms/kick'; import { ITwitterStartStreamOptions } from 'services/platforms/twitter'; import { IInstagramStartStreamOptions } from 'services/platforms/instagram'; -import { IKickStartStreamOptions } from 'services/platforms/kick'; +import { IVideo } from 'obs-studio-node'; import { TDisplayType } from 'services/settings-v2'; export enum EStreamingState { @@ -52,8 +52,8 @@ export interface IStreamInfo { youtube: TGoLiveChecklistItemState; facebook: TGoLiveChecklistItemState; tiktok: TGoLiveChecklistItemState; - kick: TGoLiveChecklistItemState; trovo: TGoLiveChecklistItemState; + kick: TGoLiveChecklistItemState; twitter: TGoLiveChecklistItemState; instagram: TGoLiveChecklistItemState; setupMultistream: TGoLiveChecklistItemState; @@ -71,9 +71,9 @@ export interface IStreamSettings { facebook?: IPlatformFlags & IFacebookStartStreamOptions; tiktok?: IPlatformFlags & ITikTokStartStreamOptions; trovo?: IPlatformFlags & ITrovoStartStreamOptions; + kick?: IPlatformFlags & IKickStartStreamOptions; twitter?: IPlatformFlags & ITwitterStartStreamOptions; instagram?: IPlatformFlags & IInstagramStartStreamOptions; - kick?: IPlatformFlags & IKickStartStreamOptions; }; customDestinations: ICustomStreamDestination[]; advancedMode: boolean; diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index 8c5ea70d749d..4b2ddc9f471e 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -139,8 +139,8 @@ export class StreamingService youtube: 'not-started', facebook: 'not-started', tiktok: 'not-started', - kick: 'not-started', trovo: 'not-started', + kick: 'not-started', twitter: 'not-started', instagram: 'not-started', setupMultistream: 'not-started', @@ -641,10 +641,6 @@ export class StreamingService if (settings.platforms.instagram?.enabled) { this.usageStatisticsService.recordFeatureUsage('StreamToInstagram'); } - - if (settings.platforms.kick?.enabled) { - this.usageStatisticsService.recordFeatureUsage('StreamToKick'); - } } /** diff --git a/app/services/user/index.ts b/app/services/user/index.ts index 88e55c632812..bffd6772184b 100644 --- a/app/services/user/index.ts +++ b/app/services/user/index.ts @@ -116,6 +116,7 @@ interface ILinkedPlatformsResponse { youtube_account?: ILinkedPlatform; tiktok_account?: ILinkedPlatform; trovo_account?: ILinkedPlatform; + kick_account?: ILinkedPlatform; streamlabs_account?: ILinkedPlatform; twitter_account?: ILinkedPlatform; user_id: number; @@ -753,6 +754,17 @@ export class UserService extends PersistentStatefulService { this.UNLINK_PLATFORM('trovo'); } + if (linkedPlatforms.kick_account) { + this.UPDATE_PLATFORM({ + type: 'kick', + username: linkedPlatforms.kick_account.platform_name, + id: linkedPlatforms.kick_account.platform_id, + token: linkedPlatforms.kick_account.access_token, + }); + } else if (this.state.auth.primaryPlatform !== 'kick') { + this.UNLINK_PLATFORM('kick'); + } + if (linkedPlatforms.streamlabs_account) { this.SET_SLID({ id: linkedPlatforms.streamlabs_account.platform_id, @@ -1283,27 +1295,6 @@ export class UserService extends PersistentStatefulService { hasRelogged: true, }; - this.UPDATE_PLATFORM(auth.platforms[auth.primaryPlatform]); - return EPlatformCallResult.Success; - } - - if (platform === 'kick') { - const auth = { - widgetToken: '', - apiToken: '', - primaryPlatform: 'kick' as TPlatform, - platforms: { - kick: { - type: 'kick' as TPlatform, - // HACK: faking kick username - username: 'linked', - token: '', - id: 'kick', - }, - }, - hasRelogged: true, - }; - this.UPDATE_PLATFORM( (auth.platforms as Record)[auth.primaryPlatform], ); diff --git a/app/styles/colors.less b/app/styles/colors.less index e4753a133613..a40a26acf00b 100644 --- a/app/styles/colors.less +++ b/app/styles/colors.less @@ -66,5 +66,5 @@ @twitter: #1DA1F2; @tiktok: white; @trovo: #19D06D; +@kick: #54FC1F; @instagram: white; -@kick: #54fc1f; diff --git a/test/data/dummy-accounts.ts b/test/data/dummy-accounts.ts index 05c030ca3489..1b66c02f2b13 100644 --- a/test/data/dummy-accounts.ts +++ b/test/data/dummy-accounts.ts @@ -3,7 +3,7 @@ import { ITestUser } from '../helpers/webdriver/user'; import { TPlatform } from 'services/platforms'; // update this list for platforms that use dummy user accounts for tests -const platforms = ['twitter', 'instagram', 'tiktok'] as const; +const platforms = ['twitter', 'instagram', 'tiktok', 'kick'] as const; type DummyUserPlatforms = typeof platforms; export type TTestDummyUserPlatforms = DummyUserPlatforms[number]; @@ -105,7 +105,7 @@ export const instagramUser1: IDummyTestUser = { }; /** - * Twitter + * X (Twitter) */ export const twitterUser1: IDummyTestUser = { @@ -122,6 +122,24 @@ export const twitterUser1: IDummyTestUser = { widgetToken: 'twitterWidgetToken1', }; +/** + * Kick + */ + +export const kickUser1: IDummyTestUser = { + email: 'kickUser1@email.com', + workerId: 'kickWorkerId1', + updated: 'kickUpdatedId1', + username: 'kickUser1', + type: 'kick', + id: 'kickId1', + token: 'kickToken1', + apiToken: 'kickApiToken1', + ingest: 'rtmps://kickIngestUrl:443/rtmp/', + streamKey: 'kickStreamKey1', + widgetToken: 'kickWidgetToken1', +}; + /** * Check if platform should use a dummy account with tests * @param platform platform for login @@ -146,6 +164,8 @@ export function getDummyUser( if (platform === 'twitter') return twitterUser1; + if (platform === 'kick') return kickUser1; + if (platform === 'tiktok') { switch (tikTokLiveScope) { case 'approved': diff --git a/test/regular/streaming/kick.ts b/test/regular/streaming/kick.ts new file mode 100644 index 000000000000..4cd0ad503e11 --- /dev/null +++ b/test/regular/streaming/kick.ts @@ -0,0 +1,32 @@ +import { skipCheckingErrorsInLog, test, useWebdriver } from '../../helpers/webdriver'; +import { + clickGoLive, + prepareToGoLive, + stopStream, + submit, + waitForSettingsWindowLoaded, + waitForStreamStart, +} from '../../helpers/modules/streaming'; +import { addDummyAccount, withUser } from '../../helpers/webdriver/user'; +import { fillForm } from '../../helpers/modules/forms'; +import { isDisplayed, waitForDisplayed } from '../../helpers/modules/core'; + +// not a react hook +// eslint-disable-next-line react-hooks/rules-of-hooks +useWebdriver(); + +test('Streaming to Kick', withUser('twitch', { multistream: true }), async t => { + await addDummyAccount('kick'); + + await prepareToGoLive(); + await clickGoLive(); + await waitForSettingsWindowLoaded(); + + // because streaming cannot be tested, check that Kick can be toggled on + await fillForm({ + kick: true, + }); + await waitForSettingsWindowLoaded(); + + t.pass(); +});