Skip to content

Commit 286bc2d

Browse files
steveseguinactions-user
authored andcommitted
fix(kick): implement 30s heartbeat for viewer count updates
Introduces a periodic heartbeat mechanism to ensure Kick viewer counts are refreshed regularly, preventing stale data between stream status events. - Adds `kickViewerHeartbeat` state tracking and 30-second interval constant. - Refactors viewer count parsing into `extractKickViewerCount` and `parseViewerCountCandidate` to handle multiple payload formats robustly. - Implements `emitKickViewerUpdate` and `syncKickViewerHeartbeat` to manage state and emission logic. - Updates `forwardLiveStatus` to use the new extraction utilities and sync heartbeat state on live status changes. [auto-enhanced]
1 parent 38cb20b commit 286bc2d

File tree

1 file changed

+271
-42
lines changed

1 file changed

+271
-42
lines changed

sources/websocket/kick.js

Lines changed: 271 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,8 @@ const extension = {
212212
};
213213

214214
const WSS_PLATFORM = 'kick';
215+
const KICK_VIEWER_HEARTBEAT_INTERVAL_MS = 30000;
216+
const KICK_VIEWER_DISCONNECT_EMIT_DEBOUNCE_MS = 1500;
215217
let extensionInitialized = false;
216218
let lastBridgeNotifyStatus = null;
217219
let lastAuthNotifyStatus = null;
@@ -220,6 +222,16 @@ let socketBridgeInitialized = false;
220222
let kickWsEventLogCount = 0;
221223
let kickWsLastEventLogAt = 0;
222224
const ignoredEventTypesLogged = new Set();
225+
const kickViewerHeartbeat = {
226+
intervalId: null,
227+
pollInFlight: false,
228+
lastKnownCount: 0,
229+
hasKnownCount: false,
230+
hadConnectedTransport: false,
231+
isLive: null,
232+
lastSentAt: 0,
233+
lastPollErrorAt: 0
234+
};
223235

224236
const LITE_MESSAGE_PREFIX = 'kick-lite-';
225237
let liteBridgeCoreReady = false;
@@ -1588,6 +1600,7 @@ function setChannelSlug(value, options = {}) {
15881600
state.channelId = null;
15891601
state.lastResolvedSlug = '';
15901602
state.autoStart.lastSlug = '';
1603+
resetKickViewerHeartbeatState();
15911604
resetThirdPartyEmoteCache();
15921605
resetChatFeed();
15931606
} else {
@@ -2039,7 +2052,248 @@ function supportsLocalSocket() {
20392052
return !!(window.ninjafy && typeof window.ninjafy.startKickWebSocket === 'function');
20402053
}
20412054

2055+
function parseViewerCountCandidate(candidate) {
2056+
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
2057+
return Math.max(0, Math.floor(candidate));
2058+
}
2059+
if (typeof candidate === 'string') {
2060+
const digits = candidate.replace(/[^0-9]/g, '');
2061+
if (!digits) {
2062+
return null;
2063+
}
2064+
const parsed = parseInt(digits, 10);
2065+
if (Number.isFinite(parsed)) {
2066+
return parsed;
2067+
}
2068+
}
2069+
return null;
2070+
}
2071+
2072+
function extractKickViewerCount(payload) {
2073+
if (!payload || typeof payload !== 'object') {
2074+
return null;
2075+
}
2076+
const viewerCountCandidates = [
2077+
payload?.viewer_count,
2078+
payload?.viewers,
2079+
payload?.viewerCount,
2080+
payload?.concurrent_viewers,
2081+
payload?.concurrent,
2082+
payload?.meta?.viewer_count,
2083+
payload?.meta?.viewers,
2084+
payload?.summary?.viewer_count,
2085+
payload?.summary?.viewers,
2086+
payload?.channel?.viewer_count,
2087+
payload?.channel?.viewers_count,
2088+
payload?.channel?.viewers,
2089+
payload?.livestream?.viewer_count,
2090+
payload?.livestream?.viewers,
2091+
payload?.stream?.viewer_count,
2092+
payload?.stream?.viewers
2093+
];
2094+
for (const candidate of viewerCountCandidates) {
2095+
const normalized = parseViewerCountCandidate(candidate);
2096+
if (normalized != null) {
2097+
return normalized;
2098+
}
2099+
}
2100+
return null;
2101+
}
2102+
2103+
function extractKickLiveFlag(payload) {
2104+
if (!payload || typeof payload !== 'object') {
2105+
return null;
2106+
}
2107+
const liveCandidates = [
2108+
payload?.is_live,
2109+
payload?.isLive,
2110+
payload?.online,
2111+
payload?.status,
2112+
payload?.stream_status,
2113+
payload?.livestream?.is_live,
2114+
payload?.livestream?.isLive,
2115+
payload?.livestream?.online,
2116+
payload?.livestream?.status,
2117+
payload?.stream?.is_live,
2118+
payload?.stream?.isLive,
2119+
payload?.stream?.online,
2120+
payload?.stream?.status
2121+
];
2122+
for (const candidate of liveCandidates) {
2123+
if (typeof candidate === 'boolean') {
2124+
return candidate;
2125+
}
2126+
if (typeof candidate === 'number') {
2127+
if (candidate === 1) return true;
2128+
if (candidate === 0) return false;
2129+
}
2130+
if (typeof candidate === 'string') {
2131+
const value = candidate.trim().toLowerCase();
2132+
if (!value) {
2133+
continue;
2134+
}
2135+
if (['live', 'online', 'started', 'active', 'on'].includes(value)) {
2136+
return true;
2137+
}
2138+
if (['offline', 'ended', 'stopped', 'inactive', 'off'].includes(value)) {
2139+
return false;
2140+
}
2141+
}
2142+
}
2143+
return null;
2144+
}
2145+
2146+
function isKickViewerTransportConnected() {
2147+
return state.bridge?.status === 'connected' || state.socket?.status === 'connected';
2148+
}
2149+
2150+
function shouldRunKickViewerHeartbeat() {
2151+
if (!state.channelSlug) {
2152+
return false;
2153+
}
2154+
if (!isKickViewerTransportConnected()) {
2155+
return false;
2156+
}
2157+
// Explicit offline status pauses heartbeat until we get a live/unknown state again.
2158+
return kickViewerHeartbeat.isLive !== false;
2159+
}
2160+
2161+
function emitKickViewerUpdate(count) {
2162+
const normalizedCount = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0;
2163+
kickViewerHeartbeat.lastKnownCount = normalizedCount;
2164+
kickViewerHeartbeat.hasKnownCount = true;
2165+
kickViewerHeartbeat.lastSentAt = Date.now();
2166+
pushMessage({
2167+
type: 'kick',
2168+
event: 'viewer_update',
2169+
meta: normalizedCount
2170+
});
2171+
return normalizedCount;
2172+
}
2173+
2174+
async function fetchKickViewerSnapshot() {
2175+
const slugInput = state.channelSlug?.trim();
2176+
if (!slugInput || !state.tokens?.access_token) {
2177+
return null;
2178+
}
2179+
const slugLower = normalizeChannel(slugInput);
2180+
if (!slugLower) {
2181+
return null;
2182+
}
2183+
const params = new URLSearchParams({ slug: slugLower });
2184+
const data = await apiFetch(`/public/v1/channels?${params.toString()}`);
2185+
const entries = Array.isArray(data?.data) ? data.data : [];
2186+
const channel = entries.find(item => normalizeChannel(item?.slug) === slugLower) || entries[0];
2187+
if (!channel || typeof channel !== 'object') {
2188+
return null;
2189+
}
2190+
return {
2191+
viewerCount: extractKickViewerCount(channel),
2192+
isLive: extractKickLiveFlag(channel)
2193+
};
2194+
}
2195+
2196+
async function sendKickViewerHeartbeat(reason = 'interval') {
2197+
if (!shouldRunKickViewerHeartbeat()) {
2198+
return;
2199+
}
2200+
if (kickViewerHeartbeat.pollInFlight) {
2201+
return;
2202+
}
2203+
kickViewerHeartbeat.pollInFlight = true;
2204+
try {
2205+
let nextViewerCount = null;
2206+
const snapshot = await fetchKickViewerSnapshot();
2207+
if (snapshot) {
2208+
if (typeof snapshot.isLive === 'boolean') {
2209+
kickViewerHeartbeat.isLive = snapshot.isLive;
2210+
}
2211+
if (snapshot.viewerCount != null) {
2212+
nextViewerCount = snapshot.viewerCount;
2213+
} else if (kickViewerHeartbeat.isLive === false) {
2214+
nextViewerCount = 0;
2215+
}
2216+
}
2217+
if (nextViewerCount == null) {
2218+
nextViewerCount = kickViewerHeartbeat.hasKnownCount ? kickViewerHeartbeat.lastKnownCount : 0;
2219+
}
2220+
emitKickViewerUpdate(nextViewerCount);
2221+
} catch (err) {
2222+
const now = Date.now();
2223+
if (!kickViewerHeartbeat.lastPollErrorAt || now - kickViewerHeartbeat.lastPollErrorAt > 120000) {
2224+
kickViewerHeartbeat.lastPollErrorAt = now;
2225+
logKickWs(`Viewer heartbeat fallback (${reason}): ${err?.message || err}`, 'warning');
2226+
}
2227+
const fallbackCount = kickViewerHeartbeat.hasKnownCount ? kickViewerHeartbeat.lastKnownCount : 0;
2228+
emitKickViewerUpdate(fallbackCount);
2229+
} finally {
2230+
kickViewerHeartbeat.pollInFlight = false;
2231+
if (!shouldRunKickViewerHeartbeat()) {
2232+
syncKickViewerHeartbeat(false);
2233+
}
2234+
}
2235+
}
2236+
2237+
function syncKickViewerHeartbeat(emitZeroOnStop = false) {
2238+
const transportConnected = isKickViewerTransportConnected();
2239+
if (transportConnected) {
2240+
kickViewerHeartbeat.hadConnectedTransport = true;
2241+
}
2242+
const shouldRun = shouldRunKickViewerHeartbeat();
2243+
if (shouldRun) {
2244+
if (!kickViewerHeartbeat.intervalId) {
2245+
kickViewerHeartbeat.intervalId = setInterval(() => {
2246+
void sendKickViewerHeartbeat('interval');
2247+
}, KICK_VIEWER_HEARTBEAT_INTERVAL_MS);
2248+
}
2249+
if (
2250+
!kickViewerHeartbeat.lastSentAt ||
2251+
(Date.now() - kickViewerHeartbeat.lastSentAt) >= KICK_VIEWER_HEARTBEAT_INTERVAL_MS
2252+
) {
2253+
void sendKickViewerHeartbeat('start');
2254+
}
2255+
return;
2256+
}
2257+
2258+
if (kickViewerHeartbeat.intervalId) {
2259+
clearInterval(kickViewerHeartbeat.intervalId);
2260+
kickViewerHeartbeat.intervalId = null;
2261+
}
2262+
2263+
if (emitZeroOnStop && !transportConnected) {
2264+
const hadSession = kickViewerHeartbeat.hadConnectedTransport || kickViewerHeartbeat.hasKnownCount;
2265+
const now = Date.now();
2266+
const shouldEmitDisconnectZero =
2267+
hadSession &&
2268+
(
2269+
!kickViewerHeartbeat.lastSentAt ||
2270+
(now - kickViewerHeartbeat.lastSentAt) > KICK_VIEWER_DISCONNECT_EMIT_DEBOUNCE_MS ||
2271+
kickViewerHeartbeat.lastKnownCount !== 0
2272+
);
2273+
if (shouldEmitDisconnectZero) {
2274+
emitKickViewerUpdate(0);
2275+
}
2276+
kickViewerHeartbeat.hadConnectedTransport = false;
2277+
kickViewerHeartbeat.isLive = false;
2278+
}
2279+
}
2280+
2281+
function resetKickViewerHeartbeatState() {
2282+
if (kickViewerHeartbeat.intervalId) {
2283+
clearInterval(kickViewerHeartbeat.intervalId);
2284+
kickViewerHeartbeat.intervalId = null;
2285+
}
2286+
kickViewerHeartbeat.pollInFlight = false;
2287+
kickViewerHeartbeat.lastKnownCount = 0;
2288+
kickViewerHeartbeat.hasKnownCount = false;
2289+
kickViewerHeartbeat.hadConnectedTransport = false;
2290+
kickViewerHeartbeat.isLive = null;
2291+
kickViewerHeartbeat.lastSentAt = 0;
2292+
kickViewerHeartbeat.lastPollErrorAt = 0;
2293+
}
2294+
20422295
function updateSocketState(payload = {}) {
2296+
syncKickViewerHeartbeat(true);
20432297
if (!els.socketState) return;
20442298
if (!supportsLocalSocket()) {
20452299
// Hide socket status in browser - it's only relevant for desktop app
@@ -2156,6 +2410,7 @@ async function connectLocalSocket(force = false) {
21562410

21572411
state.socket.connecting = true;
21582412
state.socket.status = 'connecting';
2413+
kickViewerHeartbeat.isLive = null;
21592414
updateSocketState({ status: 'connecting' });
21602415
if (!state.tokens?.access_token) {
21612416
logKickWs('No Kick access token found for socket lookup.', 'warning');
@@ -3113,6 +3368,7 @@ function connectBridge() {
31133368
const source = new EventSource(bridgeUrl, { withCredentials: false });
31143369
state.bridge.source = source;
31153370
state.bridge.status = 'connecting';
3371+
kickViewerHeartbeat.isLive = null;
31163372
updateBridgeState();
31173373

31183374
source.onopen = () => {
@@ -3366,6 +3622,7 @@ function bridgeEventMatchesCurrentChannel(packet) {
33663622
}
33673623

33683624
function updateBridgeState() {
3625+
syncKickViewerHeartbeat(true);
33693626
if (!els.bridgeState) return;
33703627
if (state.bridge.status === 'connected') {
33713628
els.bridgeState.textContent = 'Bridge connected';
@@ -4197,7 +4454,9 @@ function forwardSupportEvent(eventType, evt, bridgeMeta) {
41974454
}
41984455

41994456
function forwardLiveStatus(evt, bridgeMeta) {
4200-
const isLive = Boolean(evt?.is_live);
4457+
const explicitLiveState = extractKickLiveFlag(evt);
4458+
const isLive = explicitLiveState === true || evt?.is_live === true;
4459+
const hasExplicitOffline = explicitLiveState === false || evt?.is_live === false;
42014460
const chatname = 'Kick';
42024461
const chatmessage = isLive ? 'Stream is now LIVE' : 'Stream is now OFFLINE';
42034462
pushMessage({
@@ -4208,52 +4467,22 @@ function forwardLiveStatus(evt, bridgeMeta) {
42084467
meta: evt
42094468
});
42104469

4211-
const viewerCountCandidates = [
4212-
evt?.viewer_count,
4213-
evt?.viewers,
4214-
evt?.viewerCount,
4215-
evt?.concurrent_viewers,
4216-
evt?.concurrent,
4217-
evt?.meta?.viewer_count,
4218-
evt?.meta?.viewers,
4219-
evt?.summary?.viewer_count,
4220-
evt?.summary?.viewers,
4221-
evt?.channel?.viewer_count,
4222-
evt?.channel?.viewers_count,
4223-
evt?.channel?.viewers,
4224-
evt?.livestream?.viewer_count,
4225-
evt?.livestream?.viewers,
4226-
evt?.stream?.viewer_count,
4227-
evt?.stream?.viewers
4228-
];
4229-
let viewerTotal = null;
4230-
for (const candidate of viewerCountCandidates) {
4231-
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
4232-
viewerTotal = Math.max(0, Math.floor(candidate));
4233-
break;
4234-
}
4235-
if (typeof candidate === 'string') {
4236-
const digits = candidate.replace(/[^0-9]/g, '');
4237-
if (!digits) {
4238-
continue;
4239-
}
4240-
const parsed = parseInt(digits, 10);
4241-
if (Number.isFinite(parsed)) {
4242-
viewerTotal = parsed;
4243-
break;
4244-
}
4245-
}
4470+
if (typeof explicitLiveState === 'boolean') {
4471+
kickViewerHeartbeat.isLive = explicitLiveState;
4472+
} else if (evt?.is_live === true) {
4473+
kickViewerHeartbeat.isLive = true;
4474+
} else if (evt?.is_live === false) {
4475+
kickViewerHeartbeat.isLive = false;
42464476
}
4247-
if (!isLive) {
4477+
4478+
let viewerTotal = extractKickViewerCount(evt);
4479+
if (hasExplicitOffline) {
42484480
viewerTotal = 0;
42494481
}
42504482
if (viewerTotal != null) {
4251-
pushMessage({
4252-
type: 'kick',
4253-
event: 'viewer_update',
4254-
meta: viewerTotal
4255-
});
4483+
emitKickViewerUpdate(viewerTotal);
42564484
}
4485+
syncKickViewerHeartbeat(false);
42574486

42584487
const prefix = bridgeMeta?.verified === false ? '[LIVE ⚠]' : '[LIVE]';
42594488
if (viewerTotal != null) {

0 commit comments

Comments
 (0)