record-daemon: add live client events, fix video_file
All checks were successful
record-daemon / Build, check and test (push) Successful in 2m12s

This commit is contained in:
2026-03-28 11:08:19 +01:00
parent 16d9ddaafa
commit 3223ba74fc
6 changed files with 570 additions and 5 deletions

View File

@@ -14,7 +14,7 @@ use super::endpoints;
use super::events::GameEvent; use super::events::GameEvent;
use super::state::{ClientState, GameflowPhase}; use super::state::{ClientState, GameflowPhase};
use super::tls::create_insecure_tls_config; 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}; use crate::error::{LqpError, Result};
/// LQP Client for League Client communication. /// LQP Client for League Client communication.
@@ -409,12 +409,151 @@ impl LqpClient {
.await .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<serde_json::Value> {
// 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. /// Get local player selection from champion select.
pub async fn get_local_player_selection(&self) -> Result<serde_json::Value> { pub async fn get_local_player_selection(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::CHAMPION_SELECT_LOCAL_PLAYER) self.request("GET", endpoints::CHAMPION_SELECT_LOCAL_PLAYER)
.await .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<u64> = 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::<serde_json::Value>().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 // Metadata Fetching Methods
// ========================================================================= // =========================================================================

View File

@@ -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"; pub const LIVE_CLIENT_DATA_ACTIVE_PLAYER: &str = "/liveclientdata/activeplayer";
/// Live client player list endpoint. /// Live client player list endpoint.
pub const LIVE_CLIENT_DATA_PLAYER_LIST: &str = "/liveclientdata/playerlist"; 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. /// Local player selection in champion select.
pub const CHAMPION_SELECT_LOCAL_PLAYER: &str = "/lol-champ-select/v1/session/my-selection"; 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. /// LQP WebSocket endpoints to subscribe to.
pub const SUBSCRIBE_ENDPOINTS: &[&str] = &[ pub const SUBSCRIBE_ENDPOINTS: &[&str] = &[
GAMEFLOW_PHASE, GAMEFLOW_PHASE,

View File

@@ -16,8 +16,8 @@ pub use client::LqpClient;
pub use endpoints::{ pub use endpoints::{
ALL_RUNE_PAGES, CHAMPION_SELECT, CHAMPION_SELECT_LOCAL_PLAYER, CHAMPION_SUMMARY, ALL_RUNE_PAGES, CHAMPION_SELECT, CHAMPION_SELECT_LOCAL_PLAYER, CHAMPION_SUMMARY,
GAMEFLOW_PHASE, GAME_STATS, LIVE_CLIENT_DATA, LIVE_CLIENT_DATA_ACTIVE_PLAYER, GAMEFLOW_PHASE, GAME_STATS, LIVE_CLIENT_DATA, LIVE_CLIENT_DATA_ACTIVE_PLAYER,
LIVE_CLIENT_DATA_PLAYER_LIST, MATCH_HISTORY, RUNE_PAGES, SESSION, SUBSCRIBE_ENDPOINTS, LIVE_CLIENT_DATA_BASE_URL, LIVE_CLIENT_DATA_EVENTS, LIVE_CLIENT_DATA_PLAYER_LIST,
SUMMONER, MATCH_HISTORY, RUNE_PAGES, SESSION, SUBSCRIBE_ENDPOINTS, SUMMONER,
}; };
pub use events::{ pub use events::{
ChampSelectStartInfo, ChampionPickInfo, DeathEvent, EventData, GameEndInfo, GameEvent, ChampSelectStartInfo, ChampionPickInfo, DeathEvent, EventData, GameEndInfo, GameEvent,
@@ -26,4 +26,6 @@ pub use events::{
QueueInfo, RunePage, RuneSlot, SummonerSpells, TeamMember, QueueInfo, RunePage, RuneSlot, SummonerSpells, TeamMember,
}; };
pub use state::{ClientState, GameflowPhase}; 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,
};

View File

@@ -116,7 +116,7 @@ pub fn parse_event_from_uri(
// Handle game events (kills, deaths, objectives) // Handle game events (kills, deaths, objectives)
if uri == "/lol-game-events/v1/game-events" { if uri == "/lol-game-events/v1/game-events" {
info!("Game event received: {:?}", data); info!("Game event received: {:?}", data);
return GameEvent::from_json(data); return parse_game_event(data);
} }
// Handle ready check // Handle ready check
@@ -664,6 +664,418 @@ fn parse_ranked_stats_event(data: &serde_json::Value) -> Option<GameEvent> {
None 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<GameEvent> {
// 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<ParsedEvent> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -206,6 +206,8 @@ impl Daemon {
if let Some(creds) = watcher.credentials() { if let Some(creds) = watcher.credentials() {
self.lqp_client.connect(creds.clone()).await?; self.lqp_client.connect(creds.clone()).await?;
self.lqp_client.start_event_listener().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) => { Some(false) => {

View File

@@ -197,6 +197,11 @@ impl TimelineStore {
metadata.end_time = Some(result.end_time); metadata.end_time = Some(result.end_time);
metadata.duration = result.duration; metadata.duration = result.duration;
metadata.file_path = Some(result.path.clone()); 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.file_size = result.file_size();
metadata.finalized = true; metadata.finalized = true;