fixes lol
All checks were successful
Build and Push / build-all (push) Successful in 9m27s

This commit is contained in:
doomtube 2026-01-11 10:57:46 -05:00
parent 9e985d05f1
commit 33c20bf59d
11 changed files with 107 additions and 45 deletions

View file

@ -211,8 +211,10 @@ void StreamController::issueViewerToken(const HttpRequestPtr &,
void StreamController::heartbeat(const HttpRequestPtr &req, void StreamController::heartbeat(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback, std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId,
const std::string &streamKey) { const std::string &streamKey) {
auto token = req->getCookie("viewer_token"); // Use realm-specific cookie to support multi-stream viewing
auto token = req->getCookie("viewer_token_" + realmId);
if (token.empty()) { if (token.empty()) {
callback(jsonResp({}, k403Forbidden)); callback(jsonResp({}, k403Forbidden));
return; return;

View file

@ -17,7 +17,7 @@ public:
ADD_METHOD_TO(StreamController::getStreamStats, "/api/stream/stats/{1}", Get); ADD_METHOD_TO(StreamController::getStreamStats, "/api/stream/stats/{1}", Get);
ADD_METHOD_TO(StreamController::getActiveStreams, "/api/stream/active", Get); ADD_METHOD_TO(StreamController::getActiveStreams, "/api/stream/active", Get);
ADD_METHOD_TO(StreamController::issueViewerToken, "/api/stream/token/{1}", Get); ADD_METHOD_TO(StreamController::issueViewerToken, "/api/stream/token/{1}", Get);
ADD_METHOD_TO(StreamController::heartbeat, "/api/stream/heartbeat/{1}", Post); ADD_METHOD_TO(StreamController::heartbeat, "/api/stream/heartbeat/{1}/{2}", Post);
// OvenMediaEngine webhook endpoints // OvenMediaEngine webhook endpoints
ADD_METHOD_TO(StreamController::handleOmeWebhook, "/api/webhook/ome", Post); ADD_METHOD_TO(StreamController::handleOmeWebhook, "/api/webhook/ome", Post);
ADD_METHOD_TO(StreamController::handleOmeAdmission, "/api/webhook/ome/admission", Post); ADD_METHOD_TO(StreamController::handleOmeAdmission, "/api/webhook/ome/admission", Post);
@ -47,6 +47,7 @@ public:
void heartbeat(const HttpRequestPtr &req, void heartbeat(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback, std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &realmId,
const std::string &streamKey); const std::string &streamKey);
// OvenMediaEngine webhook handlers // OvenMediaEngine webhook handlers

View file

@ -188,17 +188,24 @@ void StatsService::updateStreamStats(const std::string& streamKey) {
// Only broadcast if stream has meaningful data or is live // Only broadcast if stream has meaningful data or is live
if (updatedStats.isLive || updatedStats.totalBytesIn > 0 || updatedStats.uniqueViewers > 0) { if (updatedStats.isLive || updatedStats.totalBytesIn > 0 || updatedStats.uniqueViewers > 0) {
// Fetch live_started_at for duration display // Fetch live_started_at and viewer_multiplier for duration display and consistent counts
auto dbClient = app().getDbClient(); auto dbClient = app().getDbClient();
*dbClient << "SELECT live_started_at FROM realms WHERE stream_key = $1" *dbClient << "SELECT live_started_at, viewer_multiplier FROM realms WHERE stream_key = $1"
<< streamKey << streamKey
>> [streamKey, updatedStats](const orm::Result& r) { >> [streamKey, updatedStats](const orm::Result& r) {
Json::Value msg; Json::Value msg;
msg["type"] = "stats_update"; msg["type"] = "stats_update";
msg["stream_key"] = streamKey; msg["stream_key"] = streamKey;
// Apply viewer multiplier for consistent display across all sources
int multiplier = 1;
if (!r.empty() && !r[0]["viewer_multiplier"].isNull()) {
multiplier = r[0]["viewer_multiplier"].as<int>();
if (multiplier < 1) multiplier = 1;
}
auto& s = msg["stats"]; auto& s = msg["stats"];
JSON_INT(s, "connections", updatedStats.uniqueViewers); JSON_INT(s, "connections", updatedStats.uniqueViewers * multiplier);
JSON_INT(s, "raw_connections", updatedStats.currentConnections); JSON_INT(s, "raw_connections", updatedStats.currentConnections);
s["bitrate"] = updatedStats.bitrate; s["bitrate"] = updatedStats.bitrate;
s["resolution"] = updatedStats.resolution; s["resolution"] = updatedStats.resolution;
@ -213,12 +220,12 @@ void StatsService::updateStreamStats(const std::string& streamKey) {
s["live_started_at"] = r[0]["live_started_at"].as<std::string>(); s["live_started_at"] = r[0]["live_started_at"].as<std::string>();
} }
// Protocol breakdown // Protocol breakdown (also apply multiplier for consistency)
auto& pc = s["protocol_connections"]; auto& pc = s["protocol_connections"];
JSON_INT(pc, "webrtc", updatedStats.protocolConnections.webrtc); JSON_INT(pc, "webrtc", updatedStats.protocolConnections.webrtc * multiplier);
JSON_INT(pc, "hls", updatedStats.protocolConnections.hls); JSON_INT(pc, "hls", updatedStats.protocolConnections.hls * multiplier);
JSON_INT(pc, "llhls", updatedStats.protocolConnections.llhls); JSON_INT(pc, "llhls", updatedStats.protocolConnections.llhls * multiplier);
JSON_INT(pc, "dash", updatedStats.protocolConnections.dash); JSON_INT(pc, "dash", updatedStats.protocolConnections.dash * multiplier);
StreamWebSocketController::broadcastStatsUpdate(msg); StreamWebSocketController::broadcastStatsUpdate(msg);
} }

View file

@ -33,6 +33,7 @@
let playerId = `player-${stream.realmId}-${Math.random().toString(36).substr(2, 9)}`; let playerId = `player-${stream.realmId}-${Math.random().toString(36).substr(2, 9)}`;
let showControls = false; let showControls = false;
let showVolumeSlider = false; let showVolumeSlider = false;
let statsInterval = null;
// Get muted/volume from stream object with defaults // Get muted/volume from stream object with defaults
$: muted = stream.muted !== undefined ? stream.muted : true; $: muted = stream.muted !== undefined ? stream.muted : true;
@ -68,6 +69,7 @@
if (viewerToken && actualStreamKey) { if (viewerToken && actualStreamKey) {
initializePlayer(); initializePlayer();
startStatsPolling();
} else { } else {
error = 'Could not get stream access'; error = 'Could not get stream access';
} }
@ -75,7 +77,31 @@
loading = false; loading = false;
}); });
function startStatsPolling() {
// Poll stats every 5 seconds to update viewer count for aggregation
statsInterval = setInterval(async () => {
try {
const response = await fetch(`/api/realms/${stream.realmId}/stats`);
if (response.ok) {
const data = await response.json();
if (data.success && data.stats) {
// Update the store with this stream's viewer count
streamTiles.setViewerCount(stream.realmId, data.stats.connections || 0);
isLive = data.stats.is_live || false;
}
}
} catch (e) {
console.error('Failed to fetch stats for tile:', stream.name, e);
}
}, 5000);
}
onDestroy(() => { onDestroy(() => {
if (statsInterval) {
clearInterval(statsInterval);
}
// Clear this stream's viewer count from the store
streamTiles.setViewerCount(stream.realmId, 0);
if (player) { if (player) {
try { try {
player.remove(); player.remove();

View file

@ -232,21 +232,21 @@
calendarDate.getFullYear() === today.getFullYear(); calendarDate.getFullYear() === today.getFullYear();
} }
$: calendarDays = getCalendarDays(calendarDate);
$: calendarMonthYear = calendarDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
$: calendarHolidays = getHolidaysForMonth(calendarDate.getFullYear(), calendarDate.getMonth());
function isHoliday(day) { function isHoliday(day) {
if (!day) return false; if (!day) return false;
return calendarHolidays.has(day); return calendarHolidays?.has(day) ?? false;
} }
function getHolidayName(day) { function getHolidayName(day) {
if (!day) return null; if (!day) return null;
const info = calendarHolidays.get(day); const info = calendarHolidays?.get(day);
return info ? info.name : null; return info ? info.name : null;
} }
$: calendarDays = getCalendarDays(calendarDate);
$: calendarMonthYear = calendarDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
$: calendarHolidays = getHolidaysForMonth(calendarDate.getFullYear(), calendarDate.getMonth());
// Timezone definitions // Timezone definitions
const timezones = [ const timezones = [
{ label: 'UTC', zone: 'UTC' }, { label: 'UTC', zone: 'UTC' },

View file

@ -27,10 +27,10 @@
const SYNC_CHECK_INTERVAL = 1000; // Check sync every second (matches server sync interval) const SYNC_CHECK_INTERVAL = 1000; // Check sync every second (matches server sync interval)
const RESTART_GRACE_PERIOD = 3000; // 3 seconds grace period during video restart const RESTART_GRACE_PERIOD = 3000; // 3 seconds grace period during video restart
const DRIFT_THRESHOLD = 2; // Seek if drift > 2 seconds (CyTube-style tight sync) const DRIFT_THRESHOLD = 5; // Seek if drift > 5 seconds (more lenient to avoid jitter)
const MIN_SYNC_INTERVAL = 1000; // Minimum 1 second between sync checks (server pushes every 1s) const MIN_SYNC_INTERVAL = 1000; // Minimum 1 second between sync checks (server pushes every 1s)
const CONTROLLER_SEEK_DEBOUNCE = 2000; // Debounce controller seeks by 2 seconds const CONTROLLER_SEEK_DEBOUNCE = 5000; // Debounce controller seeks by 5 seconds
const SEEK_RATE_LIMIT = 2000; // Minimum 2 seconds between seeks const SEEK_RATE_LIMIT = 3000; // Minimum 3 seconds between seeks
const POST_LEAD_IN_GRACE = 500; // 500ms grace period after lead-in ends const POST_LEAD_IN_GRACE = 500; // 500ms grace period after lead-in ends
// Load YouTube IFrame API // Load YouTube IFrame API

View file

@ -50,8 +50,8 @@ function loadState() {
return { return {
...defaultState, ...defaultState,
...parsed, ...parsed,
// Don't persist currentBook or showToc // Restore currentBook if it was persisted
currentBook: null, currentBook: parsed.currentBook || null,
showToc: false showToc: false
}; };
} }
@ -68,7 +68,8 @@ function saveState(state) {
try { try {
const toSave = { const toSave = {
enabled: state.enabled, enabled: state.enabled,
minimized: state.minimized minimized: state.minimized,
currentBook: state.currentBook
}; };
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave)); localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
} catch (e) { } catch (e) {
@ -96,14 +97,18 @@ function createEbookReaderStore() {
console.error('Invalid book object - missing id or filePath'); console.error('Invalid book object - missing id or filePath');
return; return;
} }
update(state => ({ update(state => {
...state, const newState = {
enabled: true, ...state,
currentBook: book, enabled: true,
progress: 0, currentBook: book,
showToc: false, progress: 0,
minimized: false showToc: false,
})); minimized: false
};
saveState(newState);
return newState;
});
}, },
// Close the reader (optionally save position first) // Close the reader (optionally save position first)
@ -113,13 +118,15 @@ function createEbookReaderStore() {
if (cfi && state.currentBook?.id) { if (cfi && state.currentBook?.id) {
persistPosition(state.currentBook.id, cfi); persistPosition(state.currentBook.id, cfi);
} }
return { const newState = {
...state, ...state,
enabled: false, enabled: false,
currentBook: null, currentBook: null,
progress: 0, progress: 0,
showToc: false showToc: false
}; };
saveState(newState);
return newState;
}); });
}, },

View file

@ -6,6 +6,7 @@ const STORAGE_KEY = 'streamTiles';
const defaultState = { const defaultState = {
enabled: false, enabled: false,
streams: [], // Array of { streamKey, name, username, realmId, offlineImageUrl?, muted: true, volume: 0.5 } streams: [], // Array of { streamKey, name, username, realmId, offlineImageUrl?, muted: true, volume: 0.5 }
viewerCounts: {}, // Map of realmId -> viewerCount for aggregating across tiles
// Grid sizing: percentage splits for resizable dividers // Grid sizing: percentage splits for resizable dividers
horizontalSplit: 50, // Percentage for left/right split (2 streams side by side) horizontalSplit: 50, // Percentage for left/right split (2 streams side by side)
verticalSplit: 50 // Percentage for top/bottom split (2x2 grid) verticalSplit: 50 // Percentage for top/bottom split (2x2 grid)
@ -138,6 +139,18 @@ function createTileStore() {
return newState; return newState;
}), }),
// Update viewer count for a specific stream (used for aggregating across tiles)
setViewerCount: (realmId, count) => update(s => {
const newViewerCounts = { ...s.viewerCounts, [realmId]: count };
// Don't persist viewer counts to storage - they're ephemeral
return { ...s, viewerCounts: newViewerCounts };
}),
// Get total viewer count across all tile streams
getTotalViewerCount: (state) => {
return Object.values(state.viewerCounts).reduce((sum, count) => sum + (count || 0), 0);
},
clear: () => { clear: () => {
const newState = { ...defaultState }; const newState = { ...defaultState };
saveToStorage(newState); saveToStorage(newState);

View file

@ -256,9 +256,9 @@
function startHeartbeat() { function startHeartbeat() {
heartbeatInterval = setInterval(async () => { heartbeatInterval = setInterval(async () => {
if (streamKey && viewerToken) { if (streamKey && viewerToken && realm) {
try { try {
const response = await fetch(`/api/stream/heartbeat/${streamKey}`, { const response = await fetch(`/api/stream/heartbeat/${realm.id}/${streamKey}`, {
method: 'POST', method: 'POST',
credentials: 'include' credentials: 'include'
}); });
@ -623,6 +623,11 @@
$: totalStreams = hasTiledStreams ? tiledStreams.length + 1 : 1; // +1 for main stream $: totalStreams = hasTiledStreams ? tiledStreams.length + 1 : 1; // +1 for main stream
$: gridClass = totalStreams === 2 ? 'grid-2' : totalStreams >= 3 ? 'grid-4' : ''; $: gridClass = totalStreams === 2 ? 'grid-2' : totalStreams >= 3 ? 'grid-4' : '';
// Aggregated viewer count across all streams (main + tiles)
$: tileViewerCount = Object.values($streamTiles.viewerCounts).reduce((sum, count) => sum + (count || 0), 0);
$: mainViewerCount = stats.isLive ? stats.connections : (realm?.viewerCount || 0);
$: totalViewerCount = hasTiledStreams && $streamTiles.enabled ? mainViewerCount + tileViewerCount : mainViewerCount;
// Track previous layout to detect changes // Track previous layout to detect changes
let prevTotalStreams = 1; let prevTotalStreams = 1;
@ -1459,7 +1464,7 @@
<div class="header-top"> <div class="header-top">
<h1 style="color: {realm.titleColor || '#ffffff'};">{realm.displayName || realm.name}</h1> <h1 style="color: {realm.titleColor || '#ffffff'};">{realm.displayName || realm.name}</h1>
<div class="live-status-badge" class:live={stats.isLive}> <div class="live-status-badge" class:live={stats.isLive}>
<span class="badge-segment">{stats.isLive ? stats.connections : realm.viewerCount} viewers</span> <span class="badge-segment">{totalViewerCount} viewers</span>
{#if stats.isLive && stats.bitrate > 0} {#if stats.isLive && stats.bitrate > 0}
<span class="badge-divider"></span> <span class="badge-divider"></span>
<span class="badge-segment">{formatBitrate(stats.bitrate)}</span> <span class="badge-segment">{formatBitrate(stats.bitrate)}</span>

View file

@ -130,21 +130,21 @@
calendarDate.getFullYear() === today.getFullYear(); calendarDate.getFullYear() === today.getFullYear();
} }
$: calendarDays = getCalendarDays(calendarDate);
$: calendarMonthYear = calendarDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
$: calendarHolidays = getHolidaysForMonth(calendarDate.getFullYear(), calendarDate.getMonth());
function isHoliday(day) { function isHoliday(day) {
if (!day) return false; if (!day) return false;
return calendarHolidays.has(day); return calendarHolidays?.has(day) ?? false;
} }
function getHolidayName(day) { function getHolidayName(day) {
if (!day) return null; if (!day) return null;
const info = calendarHolidays.get(day); const info = calendarHolidays?.get(day);
return info ? info.name : null; return info ? info.name : null;
} }
$: calendarDays = getCalendarDays(calendarDate);
$: calendarMonthYear = calendarDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
$: calendarHolidays = getHolidaysForMonth(calendarDate.getFullYear(), calendarDate.getMonth());
// Timezone definitions // Timezone definitions
const timezones = [ const timezones = [
{ label: 'UTC', zone: 'UTC' }, { label: 'UTC', zone: 'UTC' },

View file

@ -224,6 +224,7 @@
controls controls
autoplay autoplay
playsinline playsinline
on:loadedmetadata={() => { if (videoElement) videoElement.volume = 0.6; }}
> >
<track kind="captions" /> <track kind="captions" />
</video> </video>