From 3223ba74fc18dd1daae9ac740fb1ceb5bd1f6c0f Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sat, 28 Mar 2026 11:08:19 +0100 Subject: [PATCH] record-daemon: add live client events, fix video_file --- record-daemon/src/lqp/client.rs | 141 +++++++++- record-daemon/src/lqp/endpoints.rs | 5 + record-daemon/src/lqp/mod.rs | 8 +- record-daemon/src/lqp/websocket.rs | 414 +++++++++++++++++++++++++++- record-daemon/src/main.rs | 2 + record-daemon/src/timeline/store.rs | 5 + 6 files changed, 570 insertions(+), 5 deletions(-) diff --git a/record-daemon/src/lqp/client.rs b/record-daemon/src/lqp/client.rs index 86354e4..5d83355 100644 --- a/record-daemon/src/lqp/client.rs +++ b/record-daemon/src/lqp/client.rs @@ -14,7 +14,7 @@ use super::endpoints; use super::events::GameEvent; use super::state::{ClientState, GameflowPhase}; use super::tls::create_insecure_tls_config; -use super::websocket::{parse_websocket_message, ParsedEvent}; +use super::websocket::{parse_live_client_event, parse_websocket_message, ParsedEvent}; use crate::error::{LqpError, Result}; /// LQP Client for League Client communication. @@ -409,12 +409,151 @@ impl LqpClient { .await } + /// Get live client event data (kills, deaths, objectives) from port 2999. + /// This endpoint is available during games and provides real-time events. + pub async fn get_live_client_events(&self) -> Result { + // Live client data runs on port 2999, separate from the LQP client port + let url = format!( + "{}{}", + endpoints::LIVE_CLIENT_DATA_BASE_URL, + endpoints::LIVE_CLIENT_DATA_EVENTS + ); + + let response = self + .http_client + .get(&url) + .send() + .await + .map_err(|e| LqpError::ConnectionFailed(e.to_string()))?; + + if !response.status().is_success() { + return Err(LqpError::ConnectionFailed(format!( + "Live client events request failed: {}", + response.status() + )) + .into()); + } + + let json = response + .json() + .await + .map_err(|e| LqpError::EventParseError(e.to_string()))?; + + Ok(json) + } + /// Get local player selection from champion select. pub async fn get_local_player_selection(&self) -> Result { self.request("GET", endpoints::CHAMPION_SELECT_LOCAL_PLAYER) .await } + /// Start polling for live client events during a game. + /// This polls the /liveclientdata/eventdata endpoint and broadcasts events. + pub async fn start_live_client_event_poller(&self) { + let event_sender = self.event_sender.clone(); + let state = self.state.clone(); + let shutdown = self.shutdown.clone(); + let http_client = self.http_client.clone(); + + info!("Starting live client event poller"); + + tokio::spawn(async move { + let mut last_event_id: Option = None; + let mut poll_count = 0u32; + + loop { + if *shutdown.read().await { + info!("Live client event poller shutting down"); + break; + } + + // Only poll when in game + let current_phase = state.read().await.phase; + if current_phase != GameflowPhase::InProgress { + // Reset event tracking when not in game + last_event_id = None; + poll_count = 0; + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + continue; + } + + poll_count += 1; + if poll_count % 20 == 1 { + // Log every 10 seconds (20 * 500ms) + info!("Live client event poller active, polling for events..."); + } + + // Poll for events + let url = format!( + "{}{}", + endpoints::LIVE_CLIENT_DATA_BASE_URL, + endpoints::LIVE_CLIENT_DATA_EVENTS + ); + + match http_client.get(&url).send().await { + Ok(response) if response.status().is_success() => { + match response.json::().await { + Ok(events) => { + // The response has an "Events" key containing the array + let events_array = events.get("Events").and_then(|e| e.as_array()); + if let Some(events_array) = events_array { + let event_count = events_array.len(); + if event_count > 0 { + info!( + "Received {} events from live client API", + event_count + ); + } + for event in events_array { + // Check if this is a new event + let event_id = + event.get("EventID").and_then(|id| id.as_u64()); + + // Skip events we've already processed + if let Some(id) = event_id { + if let Some(last_id) = last_event_id { + if id <= last_id { + continue; + } + } + last_event_id = Some(id); + } + + // Parse and broadcast the event + if let Some(parsed) = parse_live_client_event(event) { + info!("Parsed live client event: {:?}", parsed.event); + // Update state based on event + Self::update_state_from_event(&state, &parsed.event) + .await; + + // Broadcast event + if event_sender.send(parsed).is_err() { + trace!("No event subscribers"); + } + } + } + } + } + Err(e) => { + debug!("Failed to parse live client events: {}", e); + } + } + } + Ok(response) => { + info!("Live client events request failed: {}", response.status()); + } + Err(e) => { + info!("Failed to fetch live client events: {}", e); + } + } + + // Poll every 500ms during games + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + } + }); + } + // ========================================================================= // Metadata Fetching Methods // ========================================================================= diff --git a/record-daemon/src/lqp/endpoints.rs b/record-daemon/src/lqp/endpoints.rs index 8e66df7..9067a7d 100644 --- a/record-daemon/src/lqp/endpoints.rs +++ b/record-daemon/src/lqp/endpoints.rs @@ -32,9 +32,14 @@ pub const LP_CHANGE_NOTIFICATION: &str = "/lol-ranked/v1/current-lp-change-notif pub const LIVE_CLIENT_DATA_ACTIVE_PLAYER: &str = "/liveclientdata/activeplayer"; /// Live client player list endpoint. pub const LIVE_CLIENT_DATA_PLAYER_LIST: &str = "/liveclientdata/playerlist"; +/// Live client event data endpoint (kills, deaths, objectives). +pub const LIVE_CLIENT_DATA_EVENTS: &str = "/liveclientdata/eventdata"; /// Local player selection in champion select. pub const CHAMPION_SELECT_LOCAL_PLAYER: &str = "/lol-champ-select/v1/session/my-selection"; +/// Live Client Data server base URL (runs on port 2999). +pub const LIVE_CLIENT_DATA_BASE_URL: &str = "https://127.0.0.1:2999"; + /// LQP WebSocket endpoints to subscribe to. pub const SUBSCRIBE_ENDPOINTS: &[&str] = &[ GAMEFLOW_PHASE, diff --git a/record-daemon/src/lqp/mod.rs b/record-daemon/src/lqp/mod.rs index fcfe354..5494398 100644 --- a/record-daemon/src/lqp/mod.rs +++ b/record-daemon/src/lqp/mod.rs @@ -16,8 +16,8 @@ pub use client::LqpClient; pub use endpoints::{ ALL_RUNE_PAGES, CHAMPION_SELECT, CHAMPION_SELECT_LOCAL_PLAYER, CHAMPION_SUMMARY, GAMEFLOW_PHASE, GAME_STATS, LIVE_CLIENT_DATA, LIVE_CLIENT_DATA_ACTIVE_PLAYER, - LIVE_CLIENT_DATA_PLAYER_LIST, MATCH_HISTORY, RUNE_PAGES, SESSION, SUBSCRIBE_ENDPOINTS, - SUMMONER, + LIVE_CLIENT_DATA_BASE_URL, LIVE_CLIENT_DATA_EVENTS, LIVE_CLIENT_DATA_PLAYER_LIST, + MATCH_HISTORY, RUNE_PAGES, SESSION, SUBSCRIBE_ENDPOINTS, SUMMONER, }; pub use events::{ ChampSelectStartInfo, ChampionPickInfo, DeathEvent, EventData, GameEndInfo, GameEvent, @@ -26,4 +26,6 @@ pub use events::{ QueueInfo, RunePage, RuneSlot, SummonerSpells, TeamMember, }; pub use state::{ClientState, GameflowPhase}; -pub use websocket::{parse_event_from_uri, parse_websocket_message, ParsedEvent}; +pub use websocket::{ + parse_event_from_uri, parse_live_client_event, parse_websocket_message, ParsedEvent, +}; diff --git a/record-daemon/src/lqp/websocket.rs b/record-daemon/src/lqp/websocket.rs index 432dde1..e4fbffa 100644 --- a/record-daemon/src/lqp/websocket.rs +++ b/record-daemon/src/lqp/websocket.rs @@ -116,7 +116,7 @@ pub fn parse_event_from_uri( // Handle game events (kills, deaths, objectives) if uri == "/lol-game-events/v1/game-events" { info!("Game event received: {:?}", data); - return GameEvent::from_json(data); + return parse_game_event(data); } // Handle ready check @@ -664,6 +664,418 @@ fn parse_ranked_stats_event(data: &serde_json::Value) -> Option { None } +/// Parse game events from the /lol-game-events/v1/game-events endpoint. +/// +/// This endpoint receives events like kills, deaths, and objectives. +/// The format varies but typically includes an EventName field. +fn parse_game_event(data: &serde_json::Value) -> Option { + // The game events API can send various event types + // Common event names: ChampionKill, ChampionDeath, DragonKill, BaronKill, etc. + let event_name = data.get("EventName").and_then(|n| n.as_str()).unwrap_or(""); + + info!("Parsing game event: {} -> {:?}", event_name, data); + + match event_name { + "ChampionKill" => { + // Extract kill information + let killer = data + .get("KillerName") + .and_then(|n| n.as_str()) + .unwrap_or("Unknown") + .to_string(); + + let victim = data + .get("VictimName") + .and_then(|n| n.as_str()) + .unwrap_or("Unknown") + .to_string(); + + let killer_champion = data + .get("KillerChampionName") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()); + + let victim_champion = data + .get("VictimChampionName") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()); + + // Check if it was a solo kill (no assisters) + let assisters = data + .get("Assisters") + .and_then(|a| a.as_array()) + .map(|arr| arr.len() as u32) + .unwrap_or(0); + + let solo_kill = assisters == 0; + + // Extract position if available + let position = data.get("Position").map(|p| super::events::Position { + x: p.get("x").and_then(|x| x.as_f64()).unwrap_or(0.0) as f32, + y: p.get("y").and_then(|y| y.as_f64()).unwrap_or(0.0) as f32, + }); + + // Get game time + let game_time = data.get("GameTime").and_then(|t| t.as_f64()); + + let event_json = serde_json::json!({ + "eventType": "lcu-kill", + "killer": killer, + "killerChampion": killer_champion, + "victim": victim, + "victimChampion": victim_champion, + "soloKill": solo_kill, + "assists": assisters, + "position": position, + "gameTime": game_time + }); + + GameEvent::from_json(&event_json) + } + "ChampionDeath" => { + // Extract death information + let killer = data + .get("KillerName") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()); + + let killer_champion = data + .get("KillerChampionName") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()); + + // Death cause - could be champion, minion, tower, etc. + let cause = killer + .clone() + .or_else(|| { + data.get("DeathCause") + .and_then(|c| c.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "Unknown".to_string()); + + // Extract position if available + let position = data.get("Position").map(|p| super::events::Position { + x: p.get("x").and_then(|x| x.as_f64()).unwrap_or(0.0) as f32, + y: p.get("y").and_then(|y| y.as_f64()).unwrap_or(0.0) as f32, + }); + + // Get game time + let game_time = data.get("GameTime").and_then(|t| t.as_f64()); + + let event_json = serde_json::json!({ + "eventType": "lcu-death", + "killer": killer, + "killerChampion": killer_champion, + "cause": cause, + "position": position, + "gameTime": game_time + }); + + GameEvent::from_json(&event_json) + } + "DragonKill" | "BaronKill" | "HeraldKill" | "RiftHeraldKill" | "ElderDragonKill" => { + let objective_type = match event_name { + "DragonKill" => "dragon", + "BaronKill" => "baron", + "HeraldKill" | "RiftHeraldKill" => "herald", + "ElderDragonKill" => "elderdragon", + _ => "unknown", + }; + + let team = data.get("Team").and_then(|t| t.as_u64()).unwrap_or(0) as u32; + + let game_time = data.get("GameTime").and_then(|t| t.as_f64()); + + let event_json = serde_json::json!({ + "eventType": "lcu-objective", + "objectiveType": objective_type, + "team": team, + "participated": false, // Will be determined by the caller + "gameTime": game_time + }); + + GameEvent::from_json(&event_json) + } + "TurretKill" | "InhibitorKill" | "NexusKill" => { + let objective_type = match event_name { + "TurretKill" => "tower", + "InhibitorKill" => "inhibitor", + "NexusKill" => "nexus", + _ => "unknown", + }; + + let team = data.get("Team").and_then(|t| t.as_u64()).unwrap_or(0) as u32; + + let game_time = data.get("GameTime").and_then(|t| t.as_f64()); + + let event_json = serde_json::json!({ + "eventType": "lcu-objective", + "objectiveType": objective_type, + "team": team, + "participated": false, + "gameTime": game_time + }); + + GameEvent::from_json(&event_json) + } + _ => { + // Try to parse as a generic event with eventType field + debug!( + "Unknown game event type: {}, attempting generic parse", + event_name + ); + GameEvent::from_json(data) + } + } +} + +/// Parse events from the Live Client Data API (port 2999). +/// +/// The Live Client Data API provides real-time game events including: +/// - Champion kills and deaths +/// - Objective kills (Dragon, Baron, Herald, etc.) +/// - Building destruction (Turrets, Inhibitors) +/// +/// Event format from /liveclientdata/eventdata: +/// ```json +/// { +/// "EventID": 1, +/// "EventName": "ChampionKill", +/// "EventTime": 123.456, +/// "KillerName": "Player1", +/// "VictimName": "Player2", +/// "Assisters": ["Player3"], +/// ... +/// } +/// ``` +pub fn parse_live_client_event(data: &serde_json::Value) -> Option { + let event_name = data.get("EventName").and_then(|n| n.as_str()).unwrap_or(""); + + info!("Parsing live client event: {} -> {:?}", event_name, data); + + let event = match event_name { + "ChampionKill" => { + // Extract kill information + let killer = data + .get("KillerName") + .and_then(|n| n.as_str()) + .unwrap_or("Unknown") + .to_string(); + + let victim = data + .get("VictimName") + .and_then(|n| n.as_str()) + .unwrap_or("Unknown") + .to_string(); + + // Get champion names if available + let killer_champion = data + .get("KillerChampionName") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()); + + let victim_champion = data + .get("VictimChampionName") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()); + + // Check if it was a solo kill (no assisters) + let assisters = data + .get("Assisters") + .and_then(|a| a.as_array()) + .map(|arr| arr.len() as u32) + .unwrap_or(0); + + let solo_kill = assisters == 0; + + // Get game time + let game_time = data.get("EventTime").and_then(|t| t.as_f64()); + + // Extract position if available + let position = data.get("Position").map(|p| super::events::Position { + x: p.get("x").and_then(|x| x.as_f64()).unwrap_or(0.0) as f32, + y: p.get("y").and_then(|y| y.as_f64()).unwrap_or(0.0) as f32, + }); + + let event_json = serde_json::json!({ + "eventType": "lcu-kill", + "killer": killer, + "killerChampion": killer_champion, + "victim": victim, + "victimChampion": victim_champion, + "soloKill": solo_kill, + "assists": assisters, + "position": position, + "gameTime": game_time + }); + + GameEvent::from_json(&event_json) + } + "ChampionDeath" => { + // Extract death information + let killer = data + .get("KillerName") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()); + + let killer_champion = data + .get("KillerChampionName") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()); + + // Death cause + let cause = killer + .clone() + .or_else(|| { + data.get("DeathCause") + .and_then(|c| c.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "Unknown".to_string()); + + // Get game time + let game_time = data.get("EventTime").and_then(|t| t.as_f64()); + + // Extract position if available + let position = data.get("Position").map(|p| super::events::Position { + x: p.get("x").and_then(|x| x.as_f64()).unwrap_or(0.0) as f32, + y: p.get("y").and_then(|y| y.as_f64()).unwrap_or(0.0) as f32, + }); + + let event_json = serde_json::json!({ + "eventType": "lcu-death", + "killer": killer, + "killerChampion": killer_champion, + "cause": cause, + "position": position, + "gameTime": game_time + }); + + GameEvent::from_json(&event_json) + } + "DragonKill" | "BaronKill" | "HeraldKill" | "RiftHeraldKill" | "ElderDragonKill" => { + let objective_type = match event_name { + "DragonKill" => "dragon", + "BaronKill" => "baron", + "HeraldKill" | "RiftHeraldKill" => "herald", + "ElderDragonKill" => "elderdragon", + _ => "unknown", + }; + + let _killer = data + .get("KillerName") + .and_then(|n| n.as_str()) + .unwrap_or("Unknown"); + + // Determine team based on killer (would need player list to determine team) + let team = data.get("KillerTeam").and_then(|t| t.as_u64()).unwrap_or(0) as u32; + + let game_time = data.get("EventTime").and_then(|t| t.as_f64()); + + let event_json = serde_json::json!({ + "eventType": "lcu-objective", + "objectiveType": objective_type, + "team": team, + "participated": false, + "gameTime": game_time + }); + + GameEvent::from_json(&event_json) + } + "TurretKill" | "InhibitorKill" | "NexusKill" => { + let objective_type = match event_name { + "TurretKill" => "tower", + "InhibitorKill" => "inhibitor", + "NexusKill" => "nexus", + _ => "unknown", + }; + + let team = data.get("KillerTeam").and_then(|t| t.as_u64()).unwrap_or(0) as u32; + + let game_time = data.get("EventTime").and_then(|t| t.as_f64()); + + let event_json = serde_json::json!({ + "eventType": "lcu-objective", + "objectiveType": objective_type, + "team": team, + "participated": false, + "gameTime": game_time + }); + + GameEvent::from_json(&event_json) + } + "Multikill" => { + // Multikill events (double, triple, quadra, penta kills) + let killer = data + .get("KillerName") + .and_then(|n| n.as_str()) + .unwrap_or("Unknown"); + + let kill_count = data.get("KillCount").and_then(|k| k.as_u64()).unwrap_or(2) as u32; + + let game_time = data.get("EventTime").and_then(|t| t.as_f64()); + + info!( + "Multikill event: {} got a {}-kill at {:?}", + killer, kill_count, game_time + ); + + // Don't emit a separate event for multikills, they're derived from kills + None + } + "FirstBlood" => { + let killer = data + .get("KillerName") + .and_then(|n| n.as_str()) + .unwrap_or("Unknown"); + + let victim = data + .get("VictimName") + .and_then(|n| n.as_str()) + .unwrap_or("Unknown"); + + let game_time = data.get("EventTime").and_then(|t| t.as_f64()); + + info!( + "First Blood: {} killed {} at {:?}", + killer, victim, game_time + ); + + // First blood is just a special kill, the kill event will be emitted separately + None + } + "GameStart" => { + let game_time = data + .get("EventTime") + .and_then(|t| t.as_f64()) + .unwrap_or(0.0); + + info!("Game started at {:?}", game_time); + None + } + "GameEnd" => { + let game_time = data + .get("EventTime") + .and_then(|t| t.as_f64()) + .unwrap_or(0.0); + + info!("Game ended at {:?}", game_time); + None + } + _ => { + debug!("Unknown live client event type: {}", event_name); + None + } + }; + + event.map(|e| ParsedEvent { + event: e, + raw_data: data.clone(), + uri: "/liveclientdata/eventdata".to_string(), + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/record-daemon/src/main.rs b/record-daemon/src/main.rs index 10f5649..793979a 100644 --- a/record-daemon/src/main.rs +++ b/record-daemon/src/main.rs @@ -206,6 +206,8 @@ impl Daemon { if let Some(creds) = watcher.credentials() { self.lqp_client.connect(creds.clone()).await?; self.lqp_client.start_event_listener().await?; + // Start polling for live client events (kills, deaths, objectives) + self.lqp_client.start_live_client_event_poller().await; } } Some(false) => { diff --git a/record-daemon/src/timeline/store.rs b/record-daemon/src/timeline/store.rs index e923ccb..1b77b03 100644 --- a/record-daemon/src/timeline/store.rs +++ b/record-daemon/src/timeline/store.rs @@ -197,6 +197,11 @@ impl TimelineStore { metadata.end_time = Some(result.end_time); metadata.duration = result.duration; metadata.file_path = Some(result.path.clone()); + metadata.video_file = result + .path + .file_name() + .and_then(|n| n.to_str()) + .map(String::from); metadata.file_size = result.file_size(); metadata.finalized = true;