diff --git a/backend/src/controllers/RealmController.cpp b/backend/src/controllers/RealmController.cpp index ce730f1..bffc80e 100644 --- a/backend/src/controllers/RealmController.cpp +++ b/backend/src/controllers/RealmController.cpp @@ -767,6 +767,7 @@ void RealmController::getRealmByName(const HttpRequestPtr &, "r.chat_enabled, r.chat_guests_allowed, " "r.chat_slow_mode_seconds, r.chat_retention_hours, " "r.offline_image_url, r.description, r.user_id, r.playlist_control_mode, r.playlist_whitelist, r.title_color, " + "r.live_started_at, " "u.username, u.avatar_url, u.user_color FROM realms r " "JOIN users u ON r.user_id = u.id " "WHERE r.name = $1 AND r.is_active = true" @@ -808,6 +809,10 @@ void RealmController::getRealmByName(const HttpRequestPtr &, realm["avatarUrl"] = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as(); realm["colorCode"] = r[0]["user_color"].isNull() ? "" : r[0]["user_color"].as(); realm["titleColor"] = r[0]["title_color"].isNull() ? "#ffffff" : r[0]["title_color"].as(); + // Include live_started_at for stream duration display + if (!r[0]["live_started_at"].isNull()) { + realm["liveStartedAt"] = r[0]["live_started_at"].as(); + } callback(jsonResp(resp)); } diff --git a/backend/src/services/StatsService.cpp b/backend/src/services/StatsService.cpp index c3647e1..8999ad0 100644 --- a/backend/src/services/StatsService.cpp +++ b/backend/src/services/StatsService.cpp @@ -119,8 +119,10 @@ void StatsService::pollOmeStats() { LOG_INFO << "Processing active stream: " << streamKey; // IMMEDIATELY update database to mark as live and get realm ID + // Set live_started_at only if not already set (preserves start time across polls) auto dbClient = app().getDbClient(); *dbClient << "UPDATE realms SET is_live = true, viewer_count = 0, " + "live_started_at = COALESCE(live_started_at, CURRENT_TIMESTAMP), " "updated_at = CURRENT_TIMESTAMP WHERE stream_key = $1 RETURNING id" << streamKey >> [streamKey](const orm::Result& r) { @@ -150,6 +152,7 @@ void StatsService::pollOmeStats() { if (activeStreamKeys.find(key) == activeStreamKeys.end()) { LOG_INFO << "Marking realm as offline: " << key; *db << "UPDATE realms SET is_live = false, viewer_count = 0, " + "live_started_at = NULL, " "updated_at = CURRENT_TIMESTAMP WHERE stream_key = $1" << key >> [key](const orm::Result&) { @@ -185,29 +188,44 @@ void StatsService::updateStreamStats(const std::string& streamKey) { // Only broadcast if stream has meaningful data or is live if (updatedStats.isLive || updatedStats.totalBytesIn > 0 || updatedStats.uniqueViewers > 0) { - Json::Value msg; - msg["type"] = "stats_update"; - msg["stream_key"] = streamKey; - - auto& s = msg["stats"]; - JSON_INT(s, "connections", updatedStats.uniqueViewers); - JSON_INT(s, "raw_connections", updatedStats.currentConnections); - s["bitrate"] = updatedStats.bitrate; - s["resolution"] = updatedStats.resolution; - s["fps"] = updatedStats.fps; - s["codec"] = updatedStats.codec; - s["is_live"] = updatedStats.isLive; - JSON_INT(s, "bytes_in", updatedStats.totalBytesIn); - JSON_INT(s, "bytes_out", updatedStats.totalBytesOut); - - // Protocol breakdown - auto& pc = s["protocol_connections"]; - JSON_INT(pc, "webrtc", updatedStats.protocolConnections.webrtc); - JSON_INT(pc, "hls", updatedStats.protocolConnections.hls); - JSON_INT(pc, "llhls", updatedStats.protocolConnections.llhls); - JSON_INT(pc, "dash", updatedStats.protocolConnections.dash); - - StreamWebSocketController::broadcastStatsUpdate(msg); + // Fetch live_started_at for duration display + auto dbClient = app().getDbClient(); + *dbClient << "SELECT live_started_at FROM realms WHERE stream_key = $1" + << streamKey + >> [streamKey, updatedStats](const orm::Result& r) { + Json::Value msg; + msg["type"] = "stats_update"; + msg["stream_key"] = streamKey; + + auto& s = msg["stats"]; + JSON_INT(s, "connections", updatedStats.uniqueViewers); + JSON_INT(s, "raw_connections", updatedStats.currentConnections); + s["bitrate"] = updatedStats.bitrate; + s["resolution"] = updatedStats.resolution; + s["fps"] = updatedStats.fps; + s["codec"] = updatedStats.codec; + s["is_live"] = updatedStats.isLive; + JSON_INT(s, "bytes_in", updatedStats.totalBytesIn); + JSON_INT(s, "bytes_out", updatedStats.totalBytesOut); + + // Include live_started_at for duration tracking + if (!r.empty() && !r[0]["live_started_at"].isNull()) { + s["live_started_at"] = r[0]["live_started_at"].as(); + } + + // Protocol breakdown + auto& pc = s["protocol_connections"]; + JSON_INT(pc, "webrtc", updatedStats.protocolConnections.webrtc); + JSON_INT(pc, "hls", updatedStats.protocolConnections.hls); + JSON_INT(pc, "llhls", updatedStats.protocolConnections.llhls); + JSON_INT(pc, "dash", updatedStats.protocolConnections.dash); + + StreamWebSocketController::broadcastStatsUpdate(msg); + } + >> [streamKey](const orm::DrogonDbException& e) { + LOG_ERROR << "Failed to fetch live_started_at for " << streamKey + << ": " << e.base().what(); + }; } } }); diff --git a/database/init.sql b/database/init.sql index e9ee0d2..49e89d5 100644 --- a/database/init.sql +++ b/database/init.sql @@ -1250,4 +1250,19 @@ BEGIN ) THEN ALTER TABLE users ADD COLUMN screensaver_timeout_minutes INTEGER DEFAULT 5; END IF; +END $$; + +-- ============================================ +-- LIVE STREAM DURATION TRACKING +-- ============================================ + +-- Add live_started_at column to realms table (tracks when stream went live for duration display) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'realms' AND column_name = 'live_started_at' + ) THEN + ALTER TABLE realms ADD COLUMN live_started_at TIMESTAMP WITH TIME ZONE; + END IF; END $$; \ No newline at end of file diff --git a/frontend/src/lib/components/chat/ChatPanel.svelte b/frontend/src/lib/components/chat/ChatPanel.svelte index e6a9f11..3387606 100644 --- a/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/frontend/src/lib/components/chat/ChatPanel.svelte @@ -1,5 +1,6 @@