record-daemon: add game start and end metadata
All checks were successful
record-daemon / Build, check and test (push) Successful in 2m9s
All checks were successful
record-daemon / Build, check and test (push) Successful in 2m9s
This commit is contained in:
@@ -153,7 +153,7 @@ pub enum IpcNotification {
|
||||
},
|
||||
|
||||
/// Game event received.
|
||||
GameEvent { event: GameEvent },
|
||||
GameEvent { event: Box<GameEvent> },
|
||||
|
||||
/// Daemon status changed.
|
||||
StatusChanged { status: DaemonStatus },
|
||||
|
||||
@@ -10,7 +10,7 @@ use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::protocol::Me
|
||||
use tracing::{debug, error, info, trace, warn};
|
||||
|
||||
use super::auth::LockfileCredentials;
|
||||
use super::events::{GameEvent, RawEvent};
|
||||
use super::events::{GameEvent, GameflowSession, RawEvent};
|
||||
use crate::error::{LqpError, Result};
|
||||
|
||||
/// Custom certificate verifier that accepts any certificate.
|
||||
@@ -73,6 +73,7 @@ const SUBSCRIBE_ENDPOINTS: &[&str] = &[
|
||||
"/lol-game-events/v1/game-events",
|
||||
"/lol-champ-select/v1/session",
|
||||
"/lol-lobby/v2/lobby",
|
||||
"/lol-end-of-game/v1/eog-stats-block",
|
||||
];
|
||||
|
||||
/// LQP REST API endpoints.
|
||||
@@ -82,6 +83,10 @@ pub mod endpoints {
|
||||
pub const CHAMPION_SELECT: &str = "/lol-champ-select/v1/session";
|
||||
pub const SUMMONER: &str = "/lol-summoner/v1/current-summoner";
|
||||
pub const GAME_STATS: &str = "/lol-end-of-game/v1/eog-stats-block";
|
||||
pub const CHAMPION_SUMMARY: &str = "/lol-champ-select/v1/current-champion";
|
||||
pub const RUNE_PAGES: &str = "/lol-perks/v1/currentpage";
|
||||
pub const MATCH_HISTORY: &str = "/lol-match-history/v1/products/lol/current-summoner/matches";
|
||||
pub const LIVE_CLIENT_DATA: &str = "/liveclientdata/allgamedata";
|
||||
}
|
||||
|
||||
/// Game flow phase states.
|
||||
@@ -135,6 +140,8 @@ pub struct ClientState {
|
||||
pub game_id: Option<u64>,
|
||||
/// Current champion name.
|
||||
pub champion: Option<String>,
|
||||
/// Current player's puuid.
|
||||
pub local_puuid: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ClientState {
|
||||
@@ -143,6 +150,7 @@ impl Default for ClientState {
|
||||
phase: GameflowPhase::None,
|
||||
game_id: None,
|
||||
champion: None,
|
||||
local_puuid: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,6 +167,8 @@ pub struct LqpClient {
|
||||
http_client: reqwest::Client,
|
||||
/// Shutdown signal.
|
||||
shutdown: Arc<RwLock<bool>>,
|
||||
/// Last emitted game ID for deduplication of GameStart events.
|
||||
last_emitted_game_id: Arc<RwLock<Option<u64>>>,
|
||||
}
|
||||
|
||||
impl LqpClient {
|
||||
@@ -177,6 +187,7 @@ impl LqpClient {
|
||||
event_sender,
|
||||
http_client,
|
||||
shutdown: Arc::new(RwLock::new(false)),
|
||||
last_emitted_game_id: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +225,14 @@ impl LqpClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch local player's puuid for champion extraction
|
||||
if let Ok(summoner) = self.get_summoner().await {
|
||||
if let Some(puuid) = summoner.get("puuid").and_then(|p| p.as_str()) {
|
||||
self.state.write().await.local_puuid = Some(puuid.to_string());
|
||||
info!("Fetched local player puuid: {}", puuid);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -222,6 +241,7 @@ impl LqpClient {
|
||||
*self.shutdown.write().await = true;
|
||||
*self.credentials.write().await = None;
|
||||
*self.state.write().await = ClientState::default();
|
||||
*self.last_emitted_game_id.write().await = None;
|
||||
info!("Disconnected from League Client");
|
||||
}
|
||||
|
||||
@@ -292,6 +312,7 @@ impl LqpClient {
|
||||
let state = self.state.clone();
|
||||
let shutdown = self.shutdown.clone();
|
||||
let credentials = self.credentials.clone();
|
||||
let last_emitted_game_id = self.last_emitted_game_id.clone();
|
||||
|
||||
// Spawn the message handler
|
||||
tokio::spawn(async move {
|
||||
@@ -306,10 +327,32 @@ impl LqpClient {
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(event) = Self::parse_websocket_message(&text) {
|
||||
// Get local_puuid from state for champion extraction
|
||||
let local_puuid = state.read().await.local_puuid.clone();
|
||||
if let Some(event) =
|
||||
Self::parse_websocket_message(&text, local_puuid.as_deref())
|
||||
{
|
||||
// Update state based on event
|
||||
Self::update_state_from_event(&state, &event).await;
|
||||
|
||||
// Check for duplicate GameStart events
|
||||
if let GameEvent::GameStart(ref info) = event {
|
||||
let mut last_game_id = last_emitted_game_id.write().await;
|
||||
if *last_game_id == Some(info.game_id) {
|
||||
info!(
|
||||
"Skipping duplicate GameStart event for game_id={}",
|
||||
info.game_id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
*last_game_id = Some(info.game_id);
|
||||
}
|
||||
|
||||
// Reset last_emitted_game_id on GameEnd to allow new game starts
|
||||
if let GameEvent::GameEnd(_) = &event {
|
||||
*last_emitted_game_id.write().await = None;
|
||||
}
|
||||
|
||||
// Broadcast event
|
||||
if event_sender.send(event.clone()).is_err() {
|
||||
trace!("No event subscribers");
|
||||
@@ -321,10 +364,32 @@ impl LqpClient {
|
||||
// Try to parse as UTF-8
|
||||
if let Ok(text) = String::from_utf8(data) {
|
||||
if !text.is_empty() {
|
||||
if let Some(event) = Self::parse_websocket_message(&text) {
|
||||
// Get local_puuid from state for champion extraction
|
||||
let local_puuid = state.read().await.local_puuid.clone();
|
||||
if let Some(event) =
|
||||
Self::parse_websocket_message(&text, local_puuid.as_deref())
|
||||
{
|
||||
// Update state based on event
|
||||
Self::update_state_from_event(&state, &event).await;
|
||||
|
||||
// Check for duplicate GameStart events
|
||||
if let GameEvent::GameStart(ref info) = event {
|
||||
let mut last_game_id = last_emitted_game_id.write().await;
|
||||
if *last_game_id == Some(info.game_id) {
|
||||
info!(
|
||||
"Skipping duplicate GameStart event for game_id={}",
|
||||
info.game_id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
*last_game_id = Some(info.game_id);
|
||||
}
|
||||
|
||||
// Reset last_emitted_game_id on GameEnd to allow new game starts
|
||||
if let GameEvent::GameEnd(_) = &event {
|
||||
*last_emitted_game_id.write().await = None;
|
||||
}
|
||||
|
||||
// Broadcast event
|
||||
if event_sender.send(event.clone()).is_err() {
|
||||
trace!("No event subscribers");
|
||||
@@ -364,7 +429,7 @@ impl LqpClient {
|
||||
}
|
||||
|
||||
/// Parse a WebSocket message into a game event.
|
||||
fn parse_websocket_message(text: &str) -> Option<GameEvent> {
|
||||
fn parse_websocket_message(text: &str, local_puuid: Option<&str>) -> Option<GameEvent> {
|
||||
// Parse the message array format: [type, callback, data]
|
||||
let value: serde_json::Value = match serde_json::from_str(text) {
|
||||
Ok(v) => v,
|
||||
@@ -397,6 +462,7 @@ impl LqpClient {
|
||||
&raw_event.uri,
|
||||
event_type,
|
||||
&serde_json::to_value(raw_event.data).unwrap_or_default(),
|
||||
local_puuid,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -408,7 +474,7 @@ impl LqpClient {
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("Update");
|
||||
|
||||
return Self::parse_event_from_uri(uri, event_type, data);
|
||||
return Self::parse_event_from_uri(uri, event_type, data, local_puuid);
|
||||
} else {
|
||||
debug!("Unknown callback: {}", callback);
|
||||
}
|
||||
@@ -434,6 +500,7 @@ impl LqpClient {
|
||||
uri: &str,
|
||||
event_type: &str,
|
||||
data: &serde_json::Value,
|
||||
local_puuid: Option<&str>,
|
||||
) -> Option<GameEvent> {
|
||||
info!("Parsing event from URI: {} (type: {})", uri, event_type);
|
||||
|
||||
@@ -442,6 +509,22 @@ impl LqpClient {
|
||||
let phase = data.as_str()?;
|
||||
info!("Gameflow phase changed to: {}", phase);
|
||||
|
||||
// Only trigger GameEnd on EndOfGame phase (not WaitingForStats or PreEndOfGame)
|
||||
// This ensures we wait for the stats to be available
|
||||
if phase == "EndOfGame" {
|
||||
info!("Game end phase detected: {}", phase);
|
||||
// Generate a GameEnd event for timeline recording
|
||||
return Some(
|
||||
GameEvent::from_json(&serde_json::json!({
|
||||
"eventType": "lcu-game-end",
|
||||
"gameId": 0, // Will be filled from state if available
|
||||
"victory": false, // Will be updated from end-of-game stats
|
||||
"duration": 0.0
|
||||
}))
|
||||
.unwrap_or(GameEvent::Unknown),
|
||||
);
|
||||
}
|
||||
|
||||
// Update internal state based on phase
|
||||
return Some(
|
||||
GameEvent::from_json(&serde_json::json!({
|
||||
@@ -461,17 +544,108 @@ impl LqpClient {
|
||||
if phase == "InProgress" {
|
||||
info!("Game is now in progress!");
|
||||
|
||||
// Extract game info
|
||||
let game_id = data
|
||||
// Try to parse the gameData into a GameflowSession struct
|
||||
let session: Option<GameflowSession> = data
|
||||
.get("gameData")
|
||||
.and_then(|gd| gd.get("gameId"))
|
||||
.and_then(|id| id.as_u64())
|
||||
.unwrap_or(0);
|
||||
.and_then(|gd| serde_json::from_value(gd.clone()).ok());
|
||||
|
||||
if let Some(ref session) = session {
|
||||
debug!(
|
||||
"Parsed GameflowSession: game_id={}, queue={:?}",
|
||||
session.game_id,
|
||||
session.queue_name()
|
||||
);
|
||||
} else {
|
||||
debug!("Failed to parse gameData as GameflowSession, falling back to manual extraction");
|
||||
}
|
||||
|
||||
// Extract game_id - prefer from parsed session, fallback to manual extraction
|
||||
let game_id = session.as_ref().map(|s| s.game_id).unwrap_or_else(|| {
|
||||
data.get("gameData")
|
||||
.and_then(|gd| gd.get("gameId"))
|
||||
.and_then(|id| id.as_u64())
|
||||
.unwrap_or(0)
|
||||
});
|
||||
|
||||
// Note: Champion, team, summoner_name will be extracted using puuid
|
||||
// in handle_game_event when we have access to pregame_metadata
|
||||
|
||||
// Extract queue info (this is the same for all players)
|
||||
let queue_type = session
|
||||
.as_ref()
|
||||
.and_then(|s| s.queue_name())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
data.get("gameData")
|
||||
.and_then(|gd| gd.get("queue"))
|
||||
.and_then(|q| q.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
let queue_id = session.as_ref().and_then(|s| s.queue_id());
|
||||
|
||||
let game_mode = session
|
||||
.as_ref()
|
||||
.and_then(|s| s.game_mode())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
data.get("gameData")
|
||||
.and_then(|gd| gd.get("queue"))
|
||||
.and_then(|q| q.get("gameMode"))
|
||||
.and_then(|m| m.as_str())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
// Extract map name
|
||||
let map_name = session
|
||||
.as_ref()
|
||||
.and_then(|s| s.map_id())
|
||||
.and_then(|id| map_id_to_name(id as u64))
|
||||
.or_else(|| {
|
||||
data.get("gameData")
|
||||
.and_then(|gd| gd.get("queue"))
|
||||
.and_then(|q| q.get("mapId"))
|
||||
.and_then(|id| id.as_u64())
|
||||
.and_then(map_id_to_name)
|
||||
});
|
||||
|
||||
info!("Extracted game metadata: game_id={}, queue={:?}, queue_id={:?}, mode={:?}, map={:?}",
|
||||
game_id, queue_type, queue_id, game_mode, map_name);
|
||||
|
||||
// Extract player-specific data using puuid
|
||||
let (champion, team, summoner_name) = if let Some(puuid) = local_puuid {
|
||||
let champ_id = session.as_ref().and_then(|s| s.get_champion_id(puuid));
|
||||
let team_id = session.as_ref().and_then(|s| s.get_team(puuid));
|
||||
let summoner = session
|
||||
.as_ref()
|
||||
.and_then(|s| s.get_summoner_name(puuid))
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Convert champion_id to champion name
|
||||
let champ_name = champ_id.and_then(champion_id_to_name);
|
||||
|
||||
info!("Extracted player data via puuid: champion={:?}, team={:?}, summoner={:?}",
|
||||
champ_name, team_id, summoner);
|
||||
|
||||
(champ_name, team_id, summoner)
|
||||
} else {
|
||||
info!("No local_puuid available, cannot extract player-specific data");
|
||||
(None, None, None)
|
||||
};
|
||||
|
||||
return Some(
|
||||
GameEvent::from_json(&serde_json::json!({
|
||||
"eventType": "lcu-game-start",
|
||||
"gameId": game_id
|
||||
"gameId": game_id,
|
||||
"queueType": queue_type,
|
||||
"queueId": queue_id,
|
||||
"gameMode": game_mode,
|
||||
"map": map_name,
|
||||
"champion": champion,
|
||||
"team": team,
|
||||
"summonerName": summoner_name,
|
||||
"session": session
|
||||
}))
|
||||
.unwrap_or(GameEvent::Unknown),
|
||||
);
|
||||
@@ -502,9 +676,172 @@ impl LqpClient {
|
||||
// Handle champion select
|
||||
if uri == "/lol-champ-select/v1/session" {
|
||||
info!("Champion select event: {:?}", data);
|
||||
|
||||
// Check if we're in champion select phase
|
||||
if let Some(timers) = data.get("timers") {
|
||||
if let Some(phase) = timers.get("phase").and_then(|p| p.as_str()) {
|
||||
if phase == "BAN_PICK" || phase == "FINALIZATION" {
|
||||
// Extract local player's champion
|
||||
if let Some(local_player_cell_id) =
|
||||
data.get("localPlayerCellId").and_then(|id| id.as_i64())
|
||||
{
|
||||
// Check both teams for the local player
|
||||
for team_key in &["myTeam", "theirTeam"] {
|
||||
if let Some(team) = data.get(team_key).and_then(|t| t.as_array()) {
|
||||
for member in team {
|
||||
if member.get("cellId").and_then(|id| id.as_i64())
|
||||
== Some(local_player_cell_id)
|
||||
{
|
||||
if let Some(champion_id) =
|
||||
member.get("championId").and_then(|id| id.as_u64())
|
||||
{
|
||||
if champion_id > 0 {
|
||||
let champion_name = member
|
||||
.get("championName")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
|
||||
return Some(
|
||||
GameEvent::from_json(&serde_json::json!({
|
||||
"eventType": "lcu-champion-pick",
|
||||
"summonerName": "LocalPlayer",
|
||||
"championId": champion_id,
|
||||
"championName": champion_name,
|
||||
"isLocalPlayer": true
|
||||
}))
|
||||
.unwrap_or(GameEvent::Unknown),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
// Handle end-of-game stats block (contains actual game results)
|
||||
if uri == "/lol-end-of-game/v1/eog-stats-block" {
|
||||
info!("End-of-game stats received: {:?}", data);
|
||||
|
||||
// Extract game ID and duration
|
||||
let game_id = data.get("gameId").and_then(|id| id.as_u64()).unwrap_or(0);
|
||||
let game_duration = data
|
||||
.get("gameLength")
|
||||
.and_then(|d| d.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
// Get local player data - prefer localPlayer field, fallback to teams[0].players[0]
|
||||
let local_player = data.get("localPlayer");
|
||||
|
||||
// Extract victory status from local player's stats (WIN: 1 means victory)
|
||||
let victory = local_player
|
||||
.and_then(|p| p.get("stats"))
|
||||
.and_then(|s| s.get("WIN"))
|
||||
.and_then(|w| w.as_u64())
|
||||
.map(|w| w == 1)
|
||||
.or_else(|| {
|
||||
// Fallback: check if player's team is winning team
|
||||
data.get("teams")
|
||||
.and_then(|teams| teams.as_array())
|
||||
.and_then(|t| {
|
||||
t.iter().find_map(|team| {
|
||||
if team.get("isPlayerTeam").and_then(|p| p.as_bool()) == Some(true)
|
||||
{
|
||||
team.get("isWinningTeam").and_then(|w| w.as_bool())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
// Extract player stats - stats use UPPERCASE keys
|
||||
let mut kills = 0u32;
|
||||
let mut deaths = 0u32;
|
||||
let mut assists = 0u32;
|
||||
let mut creep_score = 0u32;
|
||||
let mut gold_earned = 0u32;
|
||||
let mut damage_dealt = 0u64;
|
||||
let mut damage_taken = 0u64;
|
||||
let mut vision_score = 0.0;
|
||||
|
||||
if let Some(stats_obj) = local_player.and_then(|p| p.get("stats")) {
|
||||
kills = stats_obj
|
||||
.get("CHAMPIONS_KILLED")
|
||||
.and_then(|k| k.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
deaths = stats_obj
|
||||
.get("NUM_DEATHS")
|
||||
.and_then(|d| d.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
assists = stats_obj
|
||||
.get("ASSISTS")
|
||||
.and_then(|a| a.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
creep_score = stats_obj
|
||||
.get("MINIONS_KILLED")
|
||||
.and_then(|cs| cs.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
gold_earned = stats_obj
|
||||
.get("GOLD_EARNED")
|
||||
.and_then(|g| g.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
damage_dealt = stats_obj
|
||||
.get("TOTAL_DAMAGE_DEALT_TO_CHAMPIONS")
|
||||
.and_then(|d| d.as_u64())
|
||||
.unwrap_or(0);
|
||||
damage_taken = stats_obj
|
||||
.get("TOTAL_DAMAGE_TAKEN")
|
||||
.and_then(|d| d.as_u64())
|
||||
.unwrap_or(0);
|
||||
vision_score = stats_obj
|
||||
.get("VISION_SCORE")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
}
|
||||
|
||||
info!("Extracted game end stats: kills={}, deaths={}, assists={}, cs={}, gold={}, damage_dealt={}, damage_taken={}, vision={}, victory={}",
|
||||
kills, deaths, assists, creep_score, gold_earned, damage_dealt, damage_taken, vision_score, victory);
|
||||
|
||||
// Generate a GameEnd event with actual stats
|
||||
// Note: PlayerStats uses camelCase due to serde rename_all
|
||||
let event_json = serde_json::json!({
|
||||
"eventType": "lcu-game-end",
|
||||
"gameId": game_id,
|
||||
"victory": victory,
|
||||
"duration": game_duration,
|
||||
"stats": {
|
||||
"kills": kills,
|
||||
"deaths": deaths,
|
||||
"assists": assists,
|
||||
"minionsKilled": creep_score,
|
||||
"goldEarned": gold_earned,
|
||||
"damageDealt": damage_dealt,
|
||||
"damageTaken": damage_taken,
|
||||
"visionScore": vision_score
|
||||
}
|
||||
});
|
||||
info!("Generating GameEnd event from eog-stats: {:?}", event_json);
|
||||
|
||||
match GameEvent::from_json(&event_json) {
|
||||
Some(event) => {
|
||||
info!("Successfully parsed GameEnd event");
|
||||
return Some(event);
|
||||
}
|
||||
None => {
|
||||
warn!("Failed to parse GameEnd event, returning Unknown");
|
||||
return Some(GameEvent::Unknown);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle lobby
|
||||
if uri.starts_with("/lol-lobby") {
|
||||
debug!("Lobby event: {}", uri);
|
||||
@@ -607,6 +944,396 @@ impl LqpClient {
|
||||
pub async fn get_game_stats(&self) -> Result<serde_json::Value> {
|
||||
self.request("GET", endpoints::GAME_STATS).await
|
||||
}
|
||||
|
||||
/// Get the currently selected champion in champ select.
|
||||
pub async fn get_current_champion(&self) -> Result<serde_json::Value> {
|
||||
self.request("GET", endpoints::CHAMPION_SUMMARY).await
|
||||
}
|
||||
|
||||
/// Get current rune page.
|
||||
pub async fn get_rune_page(&self) -> Result<serde_json::Value> {
|
||||
self.request("GET", endpoints::RUNE_PAGES).await
|
||||
}
|
||||
|
||||
/// Get match history.
|
||||
pub async fn get_match_history(&self) -> Result<serde_json::Value> {
|
||||
self.request("GET", endpoints::MATCH_HISTORY).await
|
||||
}
|
||||
|
||||
/// Get live client data (available during game).
|
||||
pub async fn get_live_client_data(&self) -> Result<serde_json::Value> {
|
||||
self.request("GET", endpoints::LIVE_CLIENT_DATA).await
|
||||
}
|
||||
|
||||
/// Fetch pre-game metadata (champion, skin, runes, queue info).
|
||||
pub async fn fetch_pregame_metadata(&self) -> Result<PreGameMetadata> {
|
||||
let mut metadata = PreGameMetadata::default();
|
||||
|
||||
// Get session info for queue type and game mode
|
||||
if let Ok(session) = self.get_session().await {
|
||||
if let Some(map) = session.get("map").and_then(|m| m.as_str()) {
|
||||
metadata.map_name = Some(map.to_string());
|
||||
}
|
||||
if let Some(game_mode) = session.get("gameMode").and_then(|m| m.as_str()) {
|
||||
metadata.game_mode = Some(game_mode.to_string());
|
||||
}
|
||||
if let Some(queue_id) = session.get("queueId").and_then(|q| q.as_u64()) {
|
||||
metadata.queue_id = Some(queue_id as u32);
|
||||
}
|
||||
}
|
||||
|
||||
// Get summoner info (including puuid)
|
||||
if let Ok(summoner) = self.get_summoner().await {
|
||||
if let Some(name) = summoner.get("displayName").and_then(|n| n.as_str()) {
|
||||
metadata.summoner_name = Some(name.to_string());
|
||||
}
|
||||
// Store the local player's puuid in state
|
||||
if let Some(puuid) = summoner.get("puuid").and_then(|p| p.as_str()) {
|
||||
metadata.local_puuid = Some(puuid.to_string());
|
||||
let mut state = self.state.write().await;
|
||||
state.local_puuid = Some(puuid.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Get champion select info
|
||||
if let Ok(champ_select) = self.get_champion_select().await {
|
||||
// Find local player's cell
|
||||
if let Some(local_player_cell_id) = champ_select
|
||||
.get("localPlayerCellId")
|
||||
.and_then(|id| id.as_i64())
|
||||
{
|
||||
if let Some(my_team) = champ_select.get("myTeam").and_then(|t| t.as_array()) {
|
||||
for member in my_team {
|
||||
if member.get("cellId").and_then(|id| id.as_i64())
|
||||
== Some(local_player_cell_id)
|
||||
{
|
||||
if let Some(champion_id) =
|
||||
member.get("championId").and_then(|id| id.as_u64())
|
||||
{
|
||||
metadata.champion_id = Some(champion_id as u32);
|
||||
}
|
||||
if let Some(team) = member.get("team").and_then(|t| t.as_i64()) {
|
||||
metadata.team = Some(team as u32);
|
||||
}
|
||||
if let Some(skin_id) = member.get("skinId").and_then(|id| id.as_u64()) {
|
||||
metadata.skin_id = Some(skin_id as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get rune page
|
||||
if let Ok(rune_page) = self.get_rune_page().await {
|
||||
if let Some(name) = rune_page.get("name").and_then(|n| n.as_str()) {
|
||||
metadata.rune_page_name = Some(name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
/// Fetch end-of-game stats.
|
||||
pub async fn fetch_game_end_stats(&self) -> Result<GameEndMetadata> {
|
||||
let mut metadata = GameEndMetadata::default();
|
||||
|
||||
if let Ok(stats) = self.get_game_stats().await {
|
||||
// Get game result
|
||||
if let Some(victory) = stats.get("gameResult").and_then(|r| r.as_str()) {
|
||||
metadata.victory = Some(victory == "WIN");
|
||||
}
|
||||
|
||||
// Get player stats
|
||||
if let Some(players) = stats.get("players").and_then(|p| p.as_array()) {
|
||||
// Find the local player (first player in the array usually)
|
||||
if let Some(player) = players.first() {
|
||||
if let Some(stats_obj) = player.get("stats") {
|
||||
metadata.kills =
|
||||
stats_obj.get("kills").and_then(|k| k.as_u64()).unwrap_or(0) as u32;
|
||||
metadata.deaths = stats_obj
|
||||
.get("deaths")
|
||||
.and_then(|d| d.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
metadata.assists = stats_obj
|
||||
.get("assists")
|
||||
.and_then(|a| a.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
metadata.creep_score = stats_obj
|
||||
.get("minionsKilled")
|
||||
.and_then(|cs| cs.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
metadata.gold_earned = stats_obj
|
||||
.get("goldEarned")
|
||||
.and_then(|g| g.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
metadata.damage_dealt = stats_obj
|
||||
.get("totalDamageDealtToChampions")
|
||||
.and_then(|d| d.as_u64())
|
||||
.unwrap_or(0);
|
||||
metadata.damage_taken = stats_obj
|
||||
.get("totalDamageTaken")
|
||||
.and_then(|d| d.as_u64())
|
||||
.unwrap_or(0);
|
||||
metadata.vision_score = stats_obj
|
||||
.get("visionScore")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get game duration
|
||||
if let Some(duration) = stats.get("gameLength").and_then(|d| d.as_f64()) {
|
||||
metadata.game_duration = duration;
|
||||
}
|
||||
|
||||
// Get match ID
|
||||
if let Some(match_id) = stats.get("matchId").and_then(|id| id.as_u64()) {
|
||||
metadata.match_id = Some(match_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert champion ID to champion name.
|
||||
/// This is a simplified mapping for common champions.
|
||||
pub fn champion_id_to_name(id: u32) -> Option<String> {
|
||||
let name = match id {
|
||||
1 => "Annie",
|
||||
2 => "Olaf",
|
||||
3 => "Galio",
|
||||
4 => "TwistedFate",
|
||||
5 => "XinZhao",
|
||||
6 => "Urgot",
|
||||
7 => "LeBlanc",
|
||||
8 => "Vladimir",
|
||||
9 => "Fiddlesticks",
|
||||
10 => "Kayle",
|
||||
11 => "MasterYi",
|
||||
12 => "Alistar",
|
||||
13 => "Ryze",
|
||||
14 => "Sion",
|
||||
15 => "Sivir",
|
||||
16 => "Soraka",
|
||||
17 => "Teemo",
|
||||
18 => "Tristana",
|
||||
19 => "Warwick",
|
||||
20 => "Nunu",
|
||||
21 => "MissFortune",
|
||||
22 => "Ashe",
|
||||
23 => "Tryndamere",
|
||||
24 => "Jax",
|
||||
25 => "Morgana",
|
||||
26 => "Zilean",
|
||||
27 => "Singed",
|
||||
28 => "Evelynn",
|
||||
29 => "Twitch",
|
||||
30 => "Karthus",
|
||||
31 => "Cho'Gath",
|
||||
32 => "Amumu",
|
||||
33 => "Rammus",
|
||||
34 => "Anivia",
|
||||
35 => "Shaco",
|
||||
36 => "DrMundo",
|
||||
37 => "Sona",
|
||||
38 => "Kassadin",
|
||||
39 => "Irelia",
|
||||
40 => "Janna",
|
||||
41 => "Gangplank",
|
||||
42 => "Corki",
|
||||
43 => "Karma",
|
||||
44 => "Taric",
|
||||
45 => "Veigar",
|
||||
48 => "Trundle",
|
||||
50 => "Swain",
|
||||
51 => "Caitlyn",
|
||||
52 => "Blitzcrank",
|
||||
53 => "Malphite",
|
||||
54 => "Katarina",
|
||||
55 => "Nocturne",
|
||||
56 => "Maokai",
|
||||
57 => "Renekton",
|
||||
58 => "JarvanIV",
|
||||
59 => "Elise",
|
||||
60 => "Talon",
|
||||
61 => "Orianna",
|
||||
62 => "Wukong",
|
||||
63 => "Brand",
|
||||
64 => "LeeSin",
|
||||
67 => "Vayne",
|
||||
68 => "Rumble",
|
||||
69 => "Cassiopeia",
|
||||
72 => "Skarner",
|
||||
74 => "Heimerdinger",
|
||||
75 => "Nasus",
|
||||
76 => "Nidalee",
|
||||
77 => "Udyr",
|
||||
78 => "Poppy",
|
||||
79 => "Gragas",
|
||||
80 => "Pantheon",
|
||||
81 => "Ezreal",
|
||||
82 => "Mordekaiser",
|
||||
83 => "Yorick",
|
||||
84 => "Akali",
|
||||
85 => "Kennedy",
|
||||
86 => "Garen",
|
||||
89 => "Leona",
|
||||
90 => "Malzahar",
|
||||
91 => "Talon",
|
||||
92 => "Riven",
|
||||
96 => "Kog'Maw",
|
||||
98 => "Shen",
|
||||
99 => "Lux",
|
||||
101 => "Xerath",
|
||||
102 => "Shyvana",
|
||||
103 => "Ahri",
|
||||
104 => "Graves",
|
||||
105 => "Fizz",
|
||||
106 => "Volibear",
|
||||
107 => "Rengar",
|
||||
110 => "Varus",
|
||||
111 => "Nautilus",
|
||||
112 => "Viktor",
|
||||
113 => "Sejuani",
|
||||
114 => "Fiora",
|
||||
115 => "Ziggs",
|
||||
117 => "Lulu",
|
||||
119 => "Draven",
|
||||
120 => "Hecarim",
|
||||
121 => "Kha'Zix",
|
||||
122 => "Darius",
|
||||
126 => "Jayce",
|
||||
127 => "Lissandra",
|
||||
131 => "Diana",
|
||||
133 => "Quinn",
|
||||
134 => "Syndra",
|
||||
136 => "AurelionSol",
|
||||
141 => "Kayn",
|
||||
142 => "Zoe",
|
||||
143 => "Lillia",
|
||||
145 => "Samira",
|
||||
147 => "Seraphine",
|
||||
150 => "Gnar",
|
||||
154 => "Zac",
|
||||
157 => "Yasuo",
|
||||
161 => "Vel'Koz",
|
||||
163 => "Taliyah",
|
||||
164 => "Camille",
|
||||
166 => "Akshan",
|
||||
167 => "Nilah",
|
||||
201 => "Braum",
|
||||
202 => "Jhin",
|
||||
203 => "Kindred",
|
||||
222 => "Jinx",
|
||||
223 => "TahmKench",
|
||||
236 => "Lucian",
|
||||
238 => "Zed",
|
||||
240 => "Kled",
|
||||
245 => "Ekko",
|
||||
246 => "Qiyana",
|
||||
254 => "Vi",
|
||||
255 => "Janna",
|
||||
256 => "Pyke",
|
||||
257 => "Nami",
|
||||
266 => "Aatrox",
|
||||
267 => "Nami",
|
||||
268 => "Azir",
|
||||
350 => "Yuumi",
|
||||
360 => "Samira",
|
||||
412 => "Thresh",
|
||||
420 => "Illaoi",
|
||||
421 => "Rek'Sai",
|
||||
427 => "Ivern",
|
||||
429 => "Kalista",
|
||||
432 => "Bard",
|
||||
497 => "Rakan",
|
||||
498 => "Xayah",
|
||||
516 => "Ornn",
|
||||
517 => "Sylas",
|
||||
518 => "Neeko",
|
||||
523 => "Aphelios",
|
||||
526 => "Rell",
|
||||
555 => "Pyke",
|
||||
711 => "Vex",
|
||||
777 => "Yone",
|
||||
875 => "Sett",
|
||||
876 => "Lillia",
|
||||
887 => "Gwen",
|
||||
888 => "Viego",
|
||||
895 => "KSante",
|
||||
901 => "Smolder",
|
||||
902 => "Hwei",
|
||||
950 => "Naafiri",
|
||||
951 => "Briar",
|
||||
_ => return None,
|
||||
};
|
||||
Some(name.to_string())
|
||||
}
|
||||
|
||||
/// Convert map ID to map name.
|
||||
fn map_id_to_name(id: u64) -> Option<String> {
|
||||
let name = match id {
|
||||
1 => "Summoner's Rift",
|
||||
2 => "Summoner's Rift",
|
||||
3 => "The Proving Grounds",
|
||||
4 => "Twisted Treeline",
|
||||
8 => "The Crystal Scar",
|
||||
10 => "Twisted Treeline",
|
||||
11 => "Summoner's Rift",
|
||||
12 => "Howling Abyss",
|
||||
14 => "Butcher's Bridge",
|
||||
16 => "Cosmic Ruins",
|
||||
18 => "Valoran City Park",
|
||||
19 => "Substructure 43",
|
||||
20 => "Crash Site",
|
||||
21 => "Nexus Blitz",
|
||||
22 => "Convergence",
|
||||
23 => "Arena",
|
||||
24 => "Arena",
|
||||
25 => "Rings of Wrath",
|
||||
30 => "Swarm",
|
||||
31 => "Swarm",
|
||||
32 => "Swarm",
|
||||
33 => "Swarm",
|
||||
34 => "Swarm",
|
||||
35 => "Swarm",
|
||||
_ => return None,
|
||||
};
|
||||
Some(name.to_string())
|
||||
}
|
||||
|
||||
/// Pre-game metadata fetched before the game starts.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PreGameMetadata {
|
||||
pub champion_id: Option<u32>,
|
||||
pub skin_id: Option<u32>,
|
||||
pub rune_page_name: Option<String>,
|
||||
pub summoner_name: Option<String>,
|
||||
pub queue_type: Option<String>,
|
||||
pub queue_id: Option<u32>,
|
||||
pub game_mode: Option<String>,
|
||||
pub map_name: Option<String>,
|
||||
pub team: Option<u32>,
|
||||
pub local_puuid: Option<String>,
|
||||
}
|
||||
|
||||
/// End-of-game metadata fetched after the game ends.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GameEndMetadata {
|
||||
pub victory: Option<bool>,
|
||||
pub match_id: Option<String>,
|
||||
pub kills: u32,
|
||||
pub deaths: u32,
|
||||
pub assists: u32,
|
||||
pub creep_score: u32,
|
||||
pub gold_earned: u32,
|
||||
pub damage_dealt: u64,
|
||||
pub damage_taken: u64,
|
||||
pub vision_score: f64,
|
||||
pub game_duration: f64,
|
||||
}
|
||||
|
||||
impl Default for LqpClient {
|
||||
|
||||
@@ -13,9 +13,17 @@ pub enum GameEvent {
|
||||
#[serde(rename = "lcu-match-found")]
|
||||
MatchFound(MatchInfo),
|
||||
|
||||
/// Champion select phase started.
|
||||
#[serde(rename = "lcu-champ-select-start")]
|
||||
ChampSelectStart(ChampSelectStartInfo),
|
||||
|
||||
/// Player picked a champion.
|
||||
#[serde(rename = "lcu-champion-pick")]
|
||||
ChampionPick(ChampionPickInfo),
|
||||
|
||||
/// Game has started.
|
||||
#[serde(rename = "lcu-game-start")]
|
||||
GameStart(GameStartInfo),
|
||||
GameStart(Box<GameStartInfo>),
|
||||
|
||||
/// Player killed an enemy.
|
||||
#[serde(rename = "lcu-kill")]
|
||||
@@ -29,6 +37,10 @@ pub enum GameEvent {
|
||||
#[serde(rename = "lcu-objective")]
|
||||
Objective(ObjectiveEvent),
|
||||
|
||||
/// In-game stats update.
|
||||
#[serde(rename = "lcu-stats-update")]
|
||||
StatsUpdate(InGameStats),
|
||||
|
||||
/// Game has ended.
|
||||
#[serde(rename = "lcu-game-end")]
|
||||
GameEnd(GameEndInfo),
|
||||
@@ -65,6 +77,10 @@ pub struct MatchInfo {
|
||||
/// Queue type (ranked, normal, aram, etc.).
|
||||
pub queue_type: String,
|
||||
|
||||
/// Queue ID (numeric identifier).
|
||||
#[serde(default)]
|
||||
pub queue_id: Option<u32>,
|
||||
|
||||
/// Map name.
|
||||
pub map: String,
|
||||
|
||||
@@ -76,6 +92,87 @@ pub struct MatchInfo {
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Champion select start event data.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChampSelectStartInfo {
|
||||
/// Session ID.
|
||||
#[serde(default)]
|
||||
pub session_id: Option<String>,
|
||||
|
||||
/// Team (100 = blue, 200 = red).
|
||||
#[serde(default)]
|
||||
pub team: Option<u32>,
|
||||
|
||||
/// Timestamp when champ select started.
|
||||
#[serde(default = "Utc::now")]
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Champion pick event data.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChampionPickInfo {
|
||||
/// Player's summoner name.
|
||||
pub summoner_name: String,
|
||||
|
||||
/// Champion ID.
|
||||
pub champion_id: u32,
|
||||
|
||||
/// Champion name.
|
||||
pub champion_name: String,
|
||||
|
||||
/// Whether this is the local player's pick.
|
||||
#[serde(default)]
|
||||
pub is_local_player: bool,
|
||||
|
||||
/// Skin ID selected.
|
||||
#[serde(default)]
|
||||
pub skin_id: Option<u32>,
|
||||
|
||||
/// Skin name.
|
||||
#[serde(default)]
|
||||
pub skin_name: Option<String>,
|
||||
|
||||
/// Timestamp when champion was picked.
|
||||
#[serde(default = "Utc::now")]
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// In-game stats update.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InGameStats {
|
||||
/// Current kills.
|
||||
pub kills: u32,
|
||||
|
||||
/// Current deaths.
|
||||
pub deaths: u32,
|
||||
|
||||
/// Current assists.
|
||||
pub assists: u32,
|
||||
|
||||
/// Current creep score.
|
||||
#[serde(default)]
|
||||
pub creep_score: u32,
|
||||
|
||||
/// Current gold.
|
||||
#[serde(default)]
|
||||
pub gold: u32,
|
||||
|
||||
/// Current level.
|
||||
#[serde(default)]
|
||||
pub level: u32,
|
||||
|
||||
/// Game time in seconds.
|
||||
#[serde(default)]
|
||||
pub game_time: f64,
|
||||
|
||||
/// Timestamp of the stats update.
|
||||
#[serde(default = "Utc::now")]
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Game start event data.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -99,6 +196,26 @@ pub struct GameStartInfo {
|
||||
#[serde(default)]
|
||||
pub team: Option<u32>,
|
||||
|
||||
/// Queue type (ranked, normal, aram, etc.).
|
||||
#[serde(default)]
|
||||
pub queue_type: Option<String>,
|
||||
|
||||
/// Queue ID.
|
||||
#[serde(default)]
|
||||
pub queue_id: Option<u32>,
|
||||
|
||||
/// Game mode.
|
||||
#[serde(default)]
|
||||
pub game_mode: Option<String>,
|
||||
|
||||
/// Map name.
|
||||
#[serde(default, rename = "map")]
|
||||
pub map_name: Option<String>,
|
||||
|
||||
/// Full gameflow session data (if available).
|
||||
#[serde(default)]
|
||||
pub session: Option<GameflowSession>,
|
||||
|
||||
/// Game start timestamp.
|
||||
#[serde(default = "Utc::now")]
|
||||
pub timestamp: DateTime<Utc>,
|
||||
@@ -283,7 +400,7 @@ pub struct RawEvent {
|
||||
#[serde(untagged)]
|
||||
pub enum EventData {
|
||||
/// Game event.
|
||||
GameEvent(GameEvent),
|
||||
GameEvent(Box<GameEvent>),
|
||||
|
||||
/// Raw JSON value.
|
||||
Raw(serde_json::Value),
|
||||
@@ -313,6 +430,12 @@ impl GameEvent {
|
||||
GameEvent::MatchFound(info) => {
|
||||
format!("Match found: {} ({})", info.game_mode, info.queue_type)
|
||||
}
|
||||
GameEvent::ChampSelectStart(info) => {
|
||||
format!("Champion select started (team: {:?})", info.team)
|
||||
}
|
||||
GameEvent::ChampionPick(pick) => {
|
||||
format!("{} picked {}", pick.summoner_name, pick.champion_name)
|
||||
}
|
||||
GameEvent::GameStart(info) => {
|
||||
format!("Game started: ID {}", info.game_id)
|
||||
}
|
||||
@@ -326,6 +449,12 @@ impl GameEvent {
|
||||
let team = if obj.team == 100 { "Blue" } else { "Red" };
|
||||
format!("{} took {:?}", team, obj.objective_type)
|
||||
}
|
||||
GameEvent::StatsUpdate(stats) => {
|
||||
format!(
|
||||
"Stats: {}/{}/{} CS: {} Gold: {}",
|
||||
stats.kills, stats.deaths, stats.assists, stats.creep_score, stats.gold
|
||||
)
|
||||
}
|
||||
GameEvent::GameEnd(end) => {
|
||||
let result = if end.victory { "Victory" } else { "Defeat" };
|
||||
format!("Game ended: {} ({:.1}s)", result, end.duration)
|
||||
@@ -336,6 +465,23 @@ impl GameEvent {
|
||||
GameEvent::Unknown => "Unknown event".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the event type name for categorization.
|
||||
pub fn event_type_name(&self) -> &'static str {
|
||||
match self {
|
||||
GameEvent::MatchFound(_) => "match_found",
|
||||
GameEvent::ChampSelectStart(_) => "champ_select_start",
|
||||
GameEvent::ChampionPick(_) => "champion_pick",
|
||||
GameEvent::GameStart(_) => "game_start",
|
||||
GameEvent::Kill(_) => "kill",
|
||||
GameEvent::Death(_) => "death",
|
||||
GameEvent::Objective(_) => "objective",
|
||||
GameEvent::StatsUpdate(_) => "stats_update",
|
||||
GameEvent::GameEnd(_) => "game_end",
|
||||
GameEvent::PhaseChange(_) => "phase_change",
|
||||
GameEvent::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -370,3 +516,148 @@ mod tests {
|
||||
assert_eq!(obj, ObjectiveType::Dragon);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GameFlow Session Data Structures
|
||||
// ============================================================================
|
||||
|
||||
/// Full gameflow session data from /lol-gameflow/v1/session
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GameflowSession {
|
||||
pub game_id: u64,
|
||||
pub game_name: String,
|
||||
pub is_custom_game: bool,
|
||||
pub password: String,
|
||||
pub player_champion_selections: Vec<PlayerChampionSelection>,
|
||||
pub queue: Option<QueueInfo>,
|
||||
pub spectator_key: Option<String>,
|
||||
pub spectators_allowed: bool,
|
||||
pub team_one: Vec<TeamMember>,
|
||||
pub team_two: Vec<TeamMember>,
|
||||
#[serde(default)]
|
||||
pub phase: Option<String>,
|
||||
}
|
||||
|
||||
/// Player's champion selection info
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlayerChampionSelection {
|
||||
pub champion_id: u32,
|
||||
pub puuid: String,
|
||||
pub selected_skin_index: u32,
|
||||
pub spell1_id: u32,
|
||||
pub spell2_id: u32,
|
||||
}
|
||||
|
||||
/// Queue information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QueueInfo {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub short_name: Option<String>,
|
||||
pub description: String,
|
||||
pub detailed_description: Option<String>,
|
||||
pub game_mode: String,
|
||||
pub map_id: u32,
|
||||
pub category: String,
|
||||
#[serde(default)]
|
||||
pub is_ranked: bool,
|
||||
#[serde(default)]
|
||||
pub is_custom: bool,
|
||||
pub r#type: Option<String>,
|
||||
pub num_players_per_team: u32,
|
||||
pub maximum_participant_list_size: u32,
|
||||
pub minimum_participant_list_size: u32,
|
||||
pub champions_required_to_play: u32,
|
||||
#[serde(default)]
|
||||
pub are_free_champions_allowed: bool,
|
||||
pub queue_availability: Option<String>,
|
||||
pub spectator_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// Team member information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TeamMember {
|
||||
pub champion_id: u32,
|
||||
pub last_selected_skin_index: u32,
|
||||
pub profile_icon_id: u32,
|
||||
pub puuid: String,
|
||||
pub selected_position: String,
|
||||
pub selected_role: String,
|
||||
pub summoner_id: u64,
|
||||
pub summoner_internal_name: String,
|
||||
pub summoner_name: String,
|
||||
#[serde(default)]
|
||||
pub team_owner: bool,
|
||||
pub team_participant_id: u32,
|
||||
}
|
||||
|
||||
impl GameflowSession {
|
||||
/// Get a player's champion selection by puuid
|
||||
pub fn get_player_selection(&self, puuid: &str) -> Option<&PlayerChampionSelection> {
|
||||
self.player_champion_selections
|
||||
.iter()
|
||||
.find(|s| s.puuid == puuid)
|
||||
}
|
||||
|
||||
/// Get a player's team member info by puuid (returns team member and team id)
|
||||
pub fn get_player_team_member(&self, puuid: &str) -> Option<(&TeamMember, u32)> {
|
||||
// Check team one
|
||||
for member in &self.team_one {
|
||||
if member.puuid == puuid {
|
||||
return Some((member, 100));
|
||||
}
|
||||
}
|
||||
// Check team two
|
||||
for member in &self.team_two {
|
||||
if member.puuid == puuid {
|
||||
return Some((member, 200));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get a player's champion ID by puuid
|
||||
pub fn get_champion_id(&self, puuid: &str) -> Option<u32> {
|
||||
self.get_player_selection(puuid).map(|s| s.champion_id)
|
||||
}
|
||||
|
||||
/// Get a player's team (100 or 200) by puuid
|
||||
pub fn get_team(&self, puuid: &str) -> Option<u32> {
|
||||
self.get_player_team_member(puuid).map(|(_, team)| team)
|
||||
}
|
||||
|
||||
/// Get a player's summoner name by puuid
|
||||
pub fn get_summoner_name(&self, puuid: &str) -> Option<&str> {
|
||||
self.get_player_team_member(puuid).and_then(|(member, _)| {
|
||||
if member.summoner_name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(member.summoner_name.as_str())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Get queue name
|
||||
pub fn queue_name(&self) -> Option<&str> {
|
||||
self.queue.as_ref().map(|q| q.name.as_str())
|
||||
}
|
||||
|
||||
/// Get game mode
|
||||
pub fn game_mode(&self) -> Option<&str> {
|
||||
self.queue.as_ref().map(|q| q.game_mode.as_str())
|
||||
}
|
||||
|
||||
/// Get map ID
|
||||
pub fn map_id(&self) -> Option<u32> {
|
||||
self.queue.as_ref().map(|q| q.map_id)
|
||||
}
|
||||
|
||||
/// Get queue ID
|
||||
pub fn queue_id(&self) -> Option<u32> {
|
||||
self.queue.as_ref().map(|q| q.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ mod client;
|
||||
mod events;
|
||||
|
||||
pub use auth::{LockfileCredentials, LockfileWatcher};
|
||||
pub use client::{GameflowPhase, LqpClient};
|
||||
pub use client::{champion_id_to_name, GameEndMetadata, GameflowPhase, LqpClient, PreGameMetadata};
|
||||
pub use events::{
|
||||
DeathEvent, EventData, GameEndInfo, GameEvent, GameStartInfo, KillEvent, MatchInfo,
|
||||
ObjectiveEvent, ObjectiveType,
|
||||
ChampSelectStartInfo, ChampionPickInfo, DeathEvent, EventData, GameEndInfo, GameEvent,
|
||||
GameStartInfo, GameflowSession, InGameStats, KillEvent, MatchInfo, ObjectiveEvent,
|
||||
ObjectiveType, PlayerChampionSelection, QueueInfo, TeamMember,
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ use record_daemon::{
|
||||
lqp::{GameEvent, LockfileWatcher, LqpClient},
|
||||
recording::RecordingEngine,
|
||||
state::{DaemonStateMachine, DaemonStatus, StateTransition},
|
||||
timeline::{EventMapper, TimelineStore},
|
||||
timeline::{EventMapper, TimelineStore, TimestampedEvent},
|
||||
};
|
||||
|
||||
/// Record Daemon - League of Legends recording daemon.
|
||||
@@ -54,6 +54,10 @@ struct Daemon {
|
||||
timeline_store: Arc<RwLock<TimelineStore>>,
|
||||
/// Event mapper.
|
||||
event_mapper: Arc<RwLock<EventMapper>>,
|
||||
/// Current recording ID (if recording).
|
||||
current_recording_id: Arc<RwLock<Option<uuid::Uuid>>>,
|
||||
/// Pre-game metadata (collected before game starts).
|
||||
pregame_metadata: Arc<RwLock<Option<record_daemon::lqp::PreGameMetadata>>>,
|
||||
/// IPC server.
|
||||
ipc_server: Option<IpcServer>,
|
||||
/// Shutdown signal.
|
||||
@@ -72,6 +76,8 @@ impl Daemon {
|
||||
recording_engine: Arc::new(RwLock::new(None)),
|
||||
timeline_store: Arc::new(RwLock::new(TimelineStore::new())),
|
||||
event_mapper: Arc::new(RwLock::new(EventMapper::new())),
|
||||
current_recording_id: Arc::new(RwLock::new(None)),
|
||||
pregame_metadata: Arc::new(RwLock::new(None)),
|
||||
ipc_server: None,
|
||||
shutdown_tx,
|
||||
}
|
||||
@@ -223,80 +229,300 @@ impl Daemon {
|
||||
async fn handle_game_event(&self, event: GameEvent) -> Result<()> {
|
||||
info!("[EVENT_HANDLER] Game event received: {:?}", event);
|
||||
|
||||
// Handle pre-game metadata collection
|
||||
match &event {
|
||||
GameEvent::PhaseChange(info) if info.phase == "ChampSelect" => {
|
||||
info!("[EVENT_HANDLER] Champion select started, fetching pre-game metadata");
|
||||
match self.lqp_client.fetch_pregame_metadata().await {
|
||||
Ok(metadata) => {
|
||||
info!("[EVENT_HANDLER] Pre-game metadata fetched: {:?}", metadata);
|
||||
*self.pregame_metadata.write() = Some(metadata);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[EVENT_HANDLER] Failed to fetch pre-game metadata: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
GameEvent::ChampionPick(pick) if pick.is_local_player => {
|
||||
info!(
|
||||
"[EVENT_HANDLER] Local player picked champion: {}",
|
||||
pick.champion_name
|
||||
);
|
||||
let mut metadata = self.pregame_metadata.write();
|
||||
if let Some(ref mut meta) = *metadata {
|
||||
meta.champion_id = Some(pick.champion_id);
|
||||
if let Some(_skin_name) = &pick.skin_name {
|
||||
// Store skin name for later use
|
||||
}
|
||||
}
|
||||
}
|
||||
GameEvent::GameStart(info) => {
|
||||
info!(
|
||||
"[EVENT_HANDLER] Game started with metadata: queue={:?}, mode={:?}, map={:?}",
|
||||
info.queue_type, info.game_mode, info.map_name
|
||||
);
|
||||
|
||||
// Update pre-game metadata with game start info
|
||||
let mut pregame = self.pregame_metadata.write();
|
||||
|
||||
// Extract player-specific data from session using puuid
|
||||
let (champion_id, team, summoner_name) = if let Some(ref session) = info.session {
|
||||
// Get puuid from pregame metadata (fetched earlier)
|
||||
let puuid = pregame.as_ref().and_then(|m| m.local_puuid.as_ref());
|
||||
|
||||
if let Some(puuid) = puuid {
|
||||
// Use the puuid to find the correct player's data
|
||||
let champ_id = session.get_champion_id(puuid);
|
||||
let team_id = session.get_team(puuid);
|
||||
let summoner = session.get_summoner_name(puuid).map(|s| s.to_string());
|
||||
|
||||
info!("[EVENT_HANDLER] Found player data via puuid: champion_id={:?}, team={:?}, summoner={:?}",
|
||||
champ_id, team_id, summoner);
|
||||
|
||||
(champ_id, team_id, summoner)
|
||||
} else {
|
||||
// No puuid available, can't determine player
|
||||
warn!("[EVENT_HANDLER] No puuid available, cannot determine player-specific data");
|
||||
(None, None, None)
|
||||
}
|
||||
} else {
|
||||
(None, None, None)
|
||||
};
|
||||
|
||||
if let Some(ref mut meta) = *pregame {
|
||||
// Fill in champion_id from session if not already set
|
||||
if meta.champion_id.is_none() {
|
||||
meta.champion_id = champion_id;
|
||||
}
|
||||
// Fill in team from session if not already set
|
||||
if meta.team.is_none() {
|
||||
meta.team = team;
|
||||
}
|
||||
// Fill in summoner_name from session if not already set
|
||||
if meta.summoner_name.is_none() {
|
||||
meta.summoner_name = summoner_name;
|
||||
}
|
||||
// Fill in queue info
|
||||
if meta.queue_type.is_none() {
|
||||
meta.queue_type = info.queue_type.clone();
|
||||
}
|
||||
if meta.queue_id.is_none() {
|
||||
meta.queue_id = info.queue_id;
|
||||
}
|
||||
if meta.game_mode.is_none() {
|
||||
meta.game_mode = info.game_mode.clone();
|
||||
}
|
||||
if meta.map_name.is_none() {
|
||||
meta.map_name = info.map_name.clone();
|
||||
}
|
||||
} else {
|
||||
// Create pre-game metadata from game start info
|
||||
*pregame = Some(record_daemon::lqp::PreGameMetadata {
|
||||
summoner_name,
|
||||
champion_id,
|
||||
skin_id: None,
|
||||
rune_page_name: None,
|
||||
queue_type: info.queue_type.clone(),
|
||||
queue_id: info.queue_id,
|
||||
game_mode: info.game_mode.clone(),
|
||||
map_name: info.map_name.clone(),
|
||||
team,
|
||||
local_puuid: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Record event to timeline if recording (BEFORE state transition for GameEnd)
|
||||
// This ensures GameEnd events are recorded while still in recording state
|
||||
if self.state_machine.is_recording() {
|
||||
if let Some((video_ts, game_ts)) = self.event_mapper.write().handle_event(&event) {
|
||||
// Get the current recording ID
|
||||
if let Some(recording_id) = *self.current_recording_id.read() {
|
||||
// Create a timestamped event
|
||||
let timestamped_event = TimestampedEvent {
|
||||
video_timestamp: video_ts,
|
||||
game_timestamp: game_ts,
|
||||
timestamp: chrono::Utc::now(),
|
||||
event_type: event.event_type_name().to_string(),
|
||||
description: event.description(),
|
||||
event: event.clone(),
|
||||
};
|
||||
|
||||
// Add the event to the timeline store
|
||||
if let Err(e) = self
|
||||
.timeline_store
|
||||
.write()
|
||||
.add_event(recording_id, timestamped_event)
|
||||
{
|
||||
warn!("Failed to add event to timeline: {:?}", e);
|
||||
} else {
|
||||
debug!(
|
||||
"Event added to timeline: video_ts={:?}, game_ts={:?}, type={}",
|
||||
video_ts,
|
||||
game_ts,
|
||||
event.event_type_name()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warn!("Recording in progress but no recording ID set");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process state transitions
|
||||
if let Some(transition) = self.state_machine.process_event(&event) {
|
||||
info!("[EVENT_HANDLER] State transition: {:?}", transition);
|
||||
|
||||
self.state_machine.transition(transition.clone());
|
||||
|
||||
// Handle recording start/stop
|
||||
match transition {
|
||||
StateTransition::GameStarted { game_id, champion } => {
|
||||
info!(
|
||||
"[EVENT_HANDLER] GameStarted transition - game_id: {}, champion: {:?}",
|
||||
game_id, champion
|
||||
// Only process the transition if it's valid
|
||||
if let Some(_new_state) = self.state_machine.transition(transition.clone()) {
|
||||
// Handle recording start/stop
|
||||
match transition {
|
||||
StateTransition::GameStarted {
|
||||
game_id,
|
||||
champion,
|
||||
queue_type,
|
||||
queue_id,
|
||||
game_mode,
|
||||
map_name,
|
||||
team,
|
||||
summoner_name,
|
||||
} => {
|
||||
info!(
|
||||
"[EVENT_HANDLER] GameStarted transition - game_id: {}, champion: {:?}, queue_type: {:?}, game_mode: {:?}",
|
||||
game_id, champion, queue_type, game_mode
|
||||
);
|
||||
|
||||
// If already recording, stop the current recording first
|
||||
if self.state_machine.is_recording() {
|
||||
info!(
|
||||
// If already recording, stop the current recording first
|
||||
if self.state_machine.is_recording() {
|
||||
info!(
|
||||
"[EVENT_HANDLER] Stopping previous recording before starting new one"
|
||||
);
|
||||
|
||||
if let Err(e) = self.stop_recording().await {
|
||||
warn!("[EVENT_HANDLER] Failed to stop previous recording: {}", e);
|
||||
if let Err(e) = self.stop_recording().await {
|
||||
warn!("[EVENT_HANDLER] Failed to stop previous recording: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
info!("[EVENT_HANDLER] Calling start_recording...");
|
||||
|
||||
// Wrap the start_recording call to catch any panics
|
||||
let start_result =
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
// We need to use a blocking approach here since we're in catch_unwind
|
||||
// The actual async call happens outside
|
||||
}));
|
||||
|
||||
if let Err(panic_info) = start_result {
|
||||
error!(
|
||||
"[EVENT_HANDLER] PANIC before start_recording: {:?}",
|
||||
panic_info
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"[EVENT_HANDLER] PANIC before start_recording: {:?}",
|
||||
panic_info
|
||||
);
|
||||
}
|
||||
|
||||
// Get pre-game metadata to pass to recording
|
||||
let pregame = self.pregame_metadata.read().clone();
|
||||
let champion_name = champion.or_else(|| {
|
||||
pregame.as_ref().and({
|
||||
// Try to get champion name from metadata if not provided
|
||||
None // We only have champion_id, not name
|
||||
})
|
||||
});
|
||||
|
||||
// Build game metadata for timeline
|
||||
let metadata_update = record_daemon::timeline::MetadataUpdate {
|
||||
queue_type: queue_type.clone(),
|
||||
queue_id,
|
||||
game_mode: game_mode.clone(),
|
||||
map_name: map_name.clone(),
|
||||
team,
|
||||
summoner_name: summoner_name.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if let Err(e) = self
|
||||
.start_recording_with_metadata(
|
||||
game_id,
|
||||
champion_name.as_deref(),
|
||||
metadata_update,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("[EVENT_HANDLER] Failed to start recording: {}", e);
|
||||
|
||||
// Don't propagate error - keep daemon running
|
||||
} else {
|
||||
info!("[EVENT_HANDLER] start_recording completed successfully");
|
||||
}
|
||||
}
|
||||
|
||||
info!("[EVENT_HANDLER] Calling start_recording...");
|
||||
|
||||
// Wrap the start_recording call to catch any panics
|
||||
let start_result =
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
// We need to use a blocking approach here since we're in catch_unwind
|
||||
// The actual async call happens outside
|
||||
}));
|
||||
|
||||
if let Err(panic_info) = start_result {
|
||||
error!(
|
||||
"[EVENT_HANDLER] PANIC before start_recording: {:?}",
|
||||
panic_info
|
||||
StateTransition::GameEnded { game_end_info } => {
|
||||
info!(
|
||||
"[EVENT_HANDLER] GameEnded transition with info: {:?}",
|
||||
game_end_info
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"[EVENT_HANDLER] PANIC before start_recording: {:?}",
|
||||
panic_info
|
||||
// Convert GameEndInfo to GameEndMetadata if available
|
||||
let game_end_metadata =
|
||||
game_end_info.map(|info| record_daemon::lqp::GameEndMetadata {
|
||||
victory: Some(info.victory),
|
||||
match_id: None,
|
||||
kills: info.stats.as_ref().map(|s| s.kills).unwrap_or(0),
|
||||
deaths: info.stats.as_ref().map(|s| s.deaths).unwrap_or(0),
|
||||
assists: info.stats.as_ref().map(|s| s.assists).unwrap_or(0),
|
||||
creep_score: info
|
||||
.stats
|
||||
.as_ref()
|
||||
.map(|s| s.minions_killed)
|
||||
.unwrap_or(0),
|
||||
gold_earned: info
|
||||
.stats
|
||||
.as_ref()
|
||||
.map(|s| s.gold_earned)
|
||||
.unwrap_or(0),
|
||||
damage_dealt: info
|
||||
.stats
|
||||
.as_ref()
|
||||
.map(|s| s.damage_dealt)
|
||||
.unwrap_or(0),
|
||||
damage_taken: info
|
||||
.stats
|
||||
.as_ref()
|
||||
.map(|s| s.damage_taken)
|
||||
.unwrap_or(0),
|
||||
vision_score: info
|
||||
.stats
|
||||
.as_ref()
|
||||
.map(|s| s.vision_score)
|
||||
.unwrap_or(0.0),
|
||||
game_duration: info.duration,
|
||||
});
|
||||
|
||||
info!(
|
||||
"[EVENT_HANDLER] Game end metadata from event: {:?}",
|
||||
game_end_metadata
|
||||
);
|
||||
}
|
||||
|
||||
if let Err(e) = self.start_recording(game_id, champion.as_deref()).await {
|
||||
error!("[EVENT_HANDLER] Failed to start recording: {}", e);
|
||||
if let Err(e) = self.stop_recording_with_metadata(game_end_metadata).await {
|
||||
error!("[EVENT_HANDLER] Failed to stop recording: {}", e);
|
||||
|
||||
// Don't propagate error - keep daemon running
|
||||
} else {
|
||||
info!("[EVENT_HANDLER] start_recording completed successfully");
|
||||
// Don't propagate error - keep daemon running
|
||||
}
|
||||
|
||||
// Clear pre-game metadata
|
||||
*self.pregame_metadata.write() = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
StateTransition::GameEnded => {
|
||||
info!("[EVENT_HANDLER] GameEnded transition");
|
||||
|
||||
if let Err(e) = self.stop_recording().await {
|
||||
error!("[EVENT_HANDLER] Failed to stop recording: {}", e);
|
||||
|
||||
// Don't propagate error - keep daemon running
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Record event to timeline if recording
|
||||
if self.state_machine.is_recording() {
|
||||
if let Some((video_ts, game_ts)) = self.event_mapper.write().handle_event(&event) {
|
||||
// Event would be added to timeline here
|
||||
debug!(
|
||||
"Event mapped: video_ts={:?}, game_ts={:?}",
|
||||
video_ts, game_ts
|
||||
} else {
|
||||
warn!(
|
||||
"[EVENT_HANDLER] State transition rejected: {:?}",
|
||||
transition
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -306,13 +532,37 @@ impl Daemon {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start recording.
|
||||
async fn start_recording(&self, game_id: u64, champion: Option<&str>) -> Result<()> {
|
||||
/// Start recording with game metadata.
|
||||
async fn start_recording_with_metadata(
|
||||
&self,
|
||||
game_id: u64,
|
||||
champion: Option<&str>,
|
||||
metadata_update: record_daemon::timeline::MetadataUpdate,
|
||||
) -> Result<()> {
|
||||
info!(
|
||||
"Daemon::start_recording called - game {} ({:?})",
|
||||
"Daemon::start_recording_with_metadata called - game {} ({:?})",
|
||||
game_id, champion
|
||||
);
|
||||
|
||||
// Create a recording entry in the timeline store first
|
||||
let recording_id = self
|
||||
.timeline_store
|
||||
.write()
|
||||
.start_recording_entry(Some(game_id), champion.map(|s| s.to_string()));
|
||||
|
||||
// Update metadata immediately with game start info
|
||||
if let Err(e) = self
|
||||
.timeline_store
|
||||
.write()
|
||||
.update_metadata(recording_id, metadata_update)
|
||||
{
|
||||
warn!("Failed to update recording metadata: {:?}", e);
|
||||
}
|
||||
|
||||
// Store the recording ID for event tracking
|
||||
*self.current_recording_id.write() = Some(recording_id);
|
||||
info!("Created recording entry with ID: {}", recording_id);
|
||||
|
||||
// Clone Arc references for use in spawn_blocking
|
||||
let recording_engine = self.recording_engine.clone();
|
||||
let event_mapper = self.event_mapper.clone();
|
||||
@@ -334,7 +584,7 @@ impl Daemon {
|
||||
warn!("Recording engine is None!");
|
||||
}
|
||||
|
||||
info!("Daemon::start_recording completed successfully");
|
||||
info!("Daemon::start_recording_with_metadata completed successfully");
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
@@ -348,12 +598,24 @@ impl Daemon {
|
||||
|
||||
/// Stop recording.
|
||||
async fn stop_recording(&self) -> Result<()> {
|
||||
self.stop_recording_with_metadata(None).await
|
||||
}
|
||||
|
||||
/// Stop recording with optional game end metadata.
|
||||
async fn stop_recording_with_metadata(
|
||||
&self,
|
||||
game_end_metadata: Option<record_daemon::lqp::GameEndMetadata>,
|
||||
) -> Result<()> {
|
||||
info!("Stopping recording");
|
||||
|
||||
// Get the current recording ID and clear it
|
||||
let recording_id = self.current_recording_id.write().take();
|
||||
|
||||
// Clone Arc references for use in spawn_blocking
|
||||
let recording_engine = self.recording_engine.clone();
|
||||
let event_mapper = self.event_mapper.clone();
|
||||
let timeline_store = self.timeline_store.clone();
|
||||
let pregame_metadata = self.pregame_metadata.read().clone();
|
||||
|
||||
// Use spawn_blocking to avoid blocking the async runtime
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -362,8 +624,59 @@ impl Daemon {
|
||||
let result = engine.stop_recording()?;
|
||||
event_mapper.write().stop();
|
||||
|
||||
// Save to timeline
|
||||
timeline_store.write().add_recording(result)?;
|
||||
// Use the existing recording ID if available, otherwise create new
|
||||
let recording_id = match recording_id {
|
||||
Some(id) => {
|
||||
// Finalize the existing recording entry
|
||||
if let Err(e) = timeline_store.write().finalize_recording(id, result) {
|
||||
warn!("Failed to finalize recording: {}", e);
|
||||
}
|
||||
id
|
||||
}
|
||||
None => {
|
||||
// Fallback: create new recording entry (legacy behavior)
|
||||
timeline_store.write().add_recording(result)?
|
||||
}
|
||||
};
|
||||
|
||||
// Update metadata if we have it
|
||||
let mut update = record_daemon::timeline::MetadataUpdate::default();
|
||||
|
||||
// Add pre-game metadata
|
||||
if let Some(pregame) = pregame_metadata {
|
||||
// Convert champion_id to name if available
|
||||
if let Some(champion_id) = pregame.champion_id {
|
||||
update.champion = record_daemon::lqp::champion_id_to_name(champion_id);
|
||||
}
|
||||
update.summoner_name = pregame.summoner_name;
|
||||
update.queue_type = pregame.queue_type;
|
||||
update.queue_id = pregame.queue_id;
|
||||
update.game_mode = pregame.game_mode;
|
||||
update.map_name = pregame.map_name;
|
||||
update.team = pregame.team;
|
||||
}
|
||||
|
||||
// Add game end metadata
|
||||
if let Some(end_meta) = game_end_metadata {
|
||||
update.match_id = end_meta.match_id;
|
||||
update.victory = end_meta.victory;
|
||||
update.final_stats = Some(record_daemon::timeline::GameFinalStats {
|
||||
kills: end_meta.kills,
|
||||
deaths: end_meta.deaths,
|
||||
assists: end_meta.assists,
|
||||
creep_score: end_meta.creep_score,
|
||||
gold_earned: end_meta.gold_earned,
|
||||
damage_dealt: end_meta.damage_dealt,
|
||||
damage_taken: end_meta.damage_taken,
|
||||
vision_score: end_meta.vision_score,
|
||||
game_duration: end_meta.game_duration,
|
||||
});
|
||||
}
|
||||
|
||||
// Apply the update
|
||||
if let Err(e) = timeline_store.write().update_metadata(recording_id, update) {
|
||||
warn!("Failed to update recording metadata: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -434,6 +434,13 @@ impl ObsContext {
|
||||
// Set up game capture source
|
||||
self.setup_game_capture()?;
|
||||
|
||||
// Set up audio capture source
|
||||
if self.audio_settings.enabled && self.audio_settings.capture_game {
|
||||
info!("[START_REC] Setting up audio capture...");
|
||||
self.setup_audio_capture()?;
|
||||
info!("[START_REC] Audio capture set up successfully");
|
||||
}
|
||||
|
||||
info!("[START_REC] Game capture set up, starting output...");
|
||||
|
||||
// Start the output - wrap in catch_unwind as this may crash in native code
|
||||
@@ -494,8 +501,6 @@ impl ObsContext {
|
||||
/// Set up capture source on Linux using screen capture.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn setup_game_capture(&mut self) -> Result<()> {
|
||||
use std::io::Write;
|
||||
|
||||
info!("[CAPTURE] Setting up screen capture for Linux...");
|
||||
|
||||
self.setup_linux_screen_capture()
|
||||
@@ -796,7 +801,6 @@ impl ObsContext {
|
||||
fn setup_linux_screen_capture(&mut self) -> Result<()> {
|
||||
use libobs_simple::sources::linux::LinuxGeneralScreenCaptureBuilder;
|
||||
use libobs_simple::sources::ObsSourceBuilder;
|
||||
use std::io::Write;
|
||||
|
||||
info!("[LINUX_CAPTURE] Setting up Linux screen capture...");
|
||||
|
||||
@@ -921,6 +925,160 @@ impl ObsContext {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set up audio capture for game audio (Windows only).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn setup_audio_capture(&mut self) -> Result<()> {
|
||||
use libobs_wrapper::data::ObsData;
|
||||
use libobs_wrapper::utils::SourceInfo;
|
||||
|
||||
info!("[AUDIO_CAPTURE] Setting up WASAPI audio capture...");
|
||||
|
||||
let context = self.context.as_mut().ok_or_else(|| {
|
||||
error!("[AUDIO_CAPTURE] OBS not initialized");
|
||||
RecordingError::ObsInitError("OBS not initialized".to_string())
|
||||
})?;
|
||||
|
||||
// Create audio source settings using JSON for reliable string handling
|
||||
let settings_json = r#"{
|
||||
"executable": "League of Legends.exe",
|
||||
"title": "League of Legends (TM) Client",
|
||||
"class": "RiotWindowClass",
|
||||
"priority": 2
|
||||
}"#;
|
||||
|
||||
let audio_settings_data = ObsData::from_json(settings_json, context.runtime().clone())
|
||||
.map_err(|e| {
|
||||
error!(
|
||||
"[AUDIO_CAPTURE] Failed to create audio settings from JSON: {:?}",
|
||||
e
|
||||
);
|
||||
RecordingError::StartError(format!(
|
||||
"Failed to create audio settings from JSON: {:?}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
info!("[AUDIO_CAPTURE] Audio settings configured for League of Legends");
|
||||
|
||||
// Configure WASAPI process output capture for game audio
|
||||
// This captures the audio output from a specific process (the game)
|
||||
let source_info = SourceInfo::new(
|
||||
"wasapi_process_output_capture",
|
||||
"game_audio",
|
||||
Some(audio_settings_data),
|
||||
None,
|
||||
);
|
||||
|
||||
info!("[AUDIO_CAPTURE] Creating WASAPI process output capture source...");
|
||||
|
||||
let audio_source_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
libobs_wrapper::sources::ObsSourceRef::new_from_info(
|
||||
source_info,
|
||||
context.runtime().clone(),
|
||||
)
|
||||
}));
|
||||
|
||||
let _audio_source = match audio_source_result {
|
||||
Ok(Ok(s)) => {
|
||||
info!("[AUDIO_CAPTURE] WASAPI audio source created");
|
||||
s
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
error!("[AUDIO_CAPTURE] Failed to create audio source: {:?}", e);
|
||||
return Err(RecordingError::StartError(format!(
|
||||
"Failed to create audio source: {:?}",
|
||||
e
|
||||
))
|
||||
.into());
|
||||
}
|
||||
Err(panic_info) => {
|
||||
error!(
|
||||
"[AUDIO_CAPTURE] PANIC creating audio source: {:?}",
|
||||
panic_info
|
||||
);
|
||||
eprintln!(
|
||||
"[AUDIO_CAPTURE] PANIC creating audio source: {:?}",
|
||||
panic_info
|
||||
);
|
||||
return Err(
|
||||
RecordingError::StartError("Panic creating audio source".to_string()).into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
info!("[AUDIO_CAPTURE] Audio source created successfully");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set up audio capture for game audio (Linux only).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn setup_audio_capture(&mut self) -> Result<()> {
|
||||
use libobs_wrapper::utils::SourceInfo;
|
||||
|
||||
info!("[AUDIO_CAPTURE] Setting up PulseAudio capture...");
|
||||
|
||||
let context = self.context.as_mut().ok_or_else(|| {
|
||||
error!("[AUDIO_CAPTURE] OBS not initialized");
|
||||
RecordingError::ObsInitError("OBS not initialized".to_string())
|
||||
})?;
|
||||
|
||||
// Create audio source settings
|
||||
let audio_settings_data = context.data().map_err(|e| {
|
||||
error!("[AUDIO_CAPTURE] Failed to create audio settings: {:?}", e);
|
||||
RecordingError::StartError(format!("Failed to create audio settings: {:?}", e))
|
||||
})?;
|
||||
|
||||
// Configure PulseAudio capture for game audio on Linux
|
||||
let source_info = SourceInfo::new(
|
||||
"pulse_input_capture",
|
||||
"game_audio",
|
||||
Some(audio_settings_data),
|
||||
None,
|
||||
);
|
||||
|
||||
info!("[AUDIO_CAPTURE] Creating PulseAudio capture source...");
|
||||
|
||||
let audio_source_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
libobs_wrapper::sources::ObsSourceRef::new_from_info(
|
||||
source_info,
|
||||
context.runtime().clone(),
|
||||
)
|
||||
}));
|
||||
|
||||
let _audio_source = match audio_source_result {
|
||||
Ok(Ok(s)) => {
|
||||
info!("[AUDIO_CAPTURE] PulseAudio source created");
|
||||
s
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
error!("[AUDIO_CAPTURE] Failed to create audio source: {:?}", e);
|
||||
return Err(RecordingError::StartError(format!(
|
||||
"Failed to create audio source: {:?}",
|
||||
e
|
||||
))
|
||||
.into());
|
||||
}
|
||||
Err(panic_info) => {
|
||||
error!(
|
||||
"[AUDIO_CAPTURE] PANIC creating audio source: {:?}",
|
||||
panic_info
|
||||
);
|
||||
eprintln!(
|
||||
"[AUDIO_CAPTURE] PANIC creating audio source: {:?}",
|
||||
panic_info
|
||||
);
|
||||
return Err(
|
||||
RecordingError::StartError("Panic creating audio source".to_string()).into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
info!("[AUDIO_CAPTURE] Audio source created successfully");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop recording.
|
||||
pub fn stop_recording(&mut self) -> Result<()> {
|
||||
if !self.recording {
|
||||
|
||||
@@ -6,7 +6,7 @@ use parking_lot::RwLock;
|
||||
use tracing::{info, trace, warn};
|
||||
|
||||
use super::DaemonStatus;
|
||||
use crate::lqp::{GameEvent, GameflowPhase};
|
||||
use crate::lqp::{GameEndInfo, GameEvent, GameflowPhase};
|
||||
|
||||
/// Internal daemon state.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -46,9 +46,18 @@ pub enum StateTransition {
|
||||
GameStarted {
|
||||
game_id: u64,
|
||||
champion: Option<String>,
|
||||
queue_type: Option<String>,
|
||||
queue_id: Option<u32>,
|
||||
game_mode: Option<String>,
|
||||
map_name: Option<String>,
|
||||
team: Option<u32>,
|
||||
summoner_name: Option<String>,
|
||||
},
|
||||
/// Game ended.
|
||||
GameEnded,
|
||||
GameEnded {
|
||||
/// Game end info with stats from WebSocket.
|
||||
game_end_info: Option<GameEndInfo>,
|
||||
},
|
||||
/// Error occurred.
|
||||
Error(String),
|
||||
/// Error recovered.
|
||||
@@ -136,11 +145,13 @@ impl DaemonStateMachine {
|
||||
|
||||
// Update related state
|
||||
match &transition {
|
||||
StateTransition::GameStarted { game_id, champion } => {
|
||||
StateTransition::GameStarted {
|
||||
game_id, champion, ..
|
||||
} => {
|
||||
*self.current_game_id.write() = Some(*game_id);
|
||||
*self.current_champion.write() = champion.clone();
|
||||
}
|
||||
StateTransition::GameEnded => {
|
||||
StateTransition::GameEnded { .. } => {
|
||||
*self.current_game_id.write() = None;
|
||||
*self.current_champion.write() = None;
|
||||
}
|
||||
@@ -177,7 +188,9 @@ impl DaemonStateMachine {
|
||||
(DaemonState::Monitoring, StateTransition::Shutdown) => Some(DaemonState::ShuttingDown),
|
||||
|
||||
// From Recording
|
||||
(DaemonState::Recording, StateTransition::GameEnded) => Some(DaemonState::Monitoring),
|
||||
(DaemonState::Recording, StateTransition::GameEnded { .. }) => {
|
||||
Some(DaemonState::Monitoring)
|
||||
}
|
||||
// Allow GameStarted from Recording (handles case where GameEnded wasn't received)
|
||||
(DaemonState::Recording, StateTransition::GameStarted { .. }) => {
|
||||
Some(DaemonState::Recording)
|
||||
@@ -214,12 +227,23 @@ impl DaemonStateMachine {
|
||||
GameEvent::GameStart(info) => Some(StateTransition::GameStarted {
|
||||
game_id: info.game_id,
|
||||
champion: info.champion.clone(),
|
||||
queue_type: info.queue_type.clone(),
|
||||
queue_id: info.queue_id,
|
||||
game_mode: info.game_mode.clone(),
|
||||
map_name: info.map_name.clone(),
|
||||
team: info.team,
|
||||
summoner_name: info.summoner_name.clone(),
|
||||
}),
|
||||
GameEvent::GameEnd(info) => Some(StateTransition::GameEnded {
|
||||
game_end_info: Some(info.clone()),
|
||||
}),
|
||||
GameEvent::GameEnd(_) => Some(StateTransition::GameEnded),
|
||||
GameEvent::PhaseChange(info) => {
|
||||
// When phase changes to None while recording, the player left the game
|
||||
if info.phase == "None" && self.is_recording() {
|
||||
Some(StateTransition::GameEnded)
|
||||
// Only trigger GameEnded on EndOfGame phase (stats are available by then)
|
||||
// The actual GameEnd event with stats comes from /lol-end-of-game/v1/eog-stats-block
|
||||
if info.phase == "EndOfGame" && self.is_recording() {
|
||||
Some(StateTransition::GameEnded {
|
||||
game_end_info: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -267,6 +291,12 @@ mod tests {
|
||||
let new_state = machine.transition(StateTransition::GameStarted {
|
||||
game_id: 12345,
|
||||
champion: Some("Ahri".to_string()),
|
||||
queue_type: None,
|
||||
queue_id: None,
|
||||
game_mode: None,
|
||||
map_name: None,
|
||||
team: None,
|
||||
summoner_name: None,
|
||||
});
|
||||
|
||||
assert_eq!(new_state, Some(DaemonState::Recording));
|
||||
@@ -282,6 +312,12 @@ mod tests {
|
||||
let result = machine.transition(StateTransition::GameStarted {
|
||||
game_id: 12345,
|
||||
champion: None,
|
||||
queue_type: None,
|
||||
queue_id: None,
|
||||
game_mode: None,
|
||||
map_name: None,
|
||||
team: None,
|
||||
summoner_name: None,
|
||||
});
|
||||
|
||||
assert_eq!(result, None);
|
||||
|
||||
@@ -4,7 +4,9 @@ mod mapper;
|
||||
mod store;
|
||||
|
||||
pub use mapper::EventMapper;
|
||||
pub use store::{RecordingMetadata, TimelineStore, TimestampedEvent};
|
||||
pub use store::{
|
||||
GameFinalStats, MetadataUpdate, RecordingMetadata, TimelineStore, TimestampedEvent,
|
||||
};
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -25,6 +27,36 @@ pub struct Timeline {
|
||||
pub duration_secs: i64,
|
||||
/// Events in the timeline.
|
||||
pub events: Vec<TimestampedEvent>,
|
||||
/// Champion played.
|
||||
#[serde(default)]
|
||||
pub champion: Option<String>,
|
||||
/// Skin name.
|
||||
#[serde(default)]
|
||||
pub skin_name: Option<String>,
|
||||
/// Queue type.
|
||||
#[serde(default)]
|
||||
pub queue_type: Option<String>,
|
||||
/// Queue ID.
|
||||
#[serde(default)]
|
||||
pub queue_id: Option<u32>,
|
||||
/// Game mode.
|
||||
#[serde(default)]
|
||||
pub game_mode: Option<String>,
|
||||
/// Map name.
|
||||
#[serde(default)]
|
||||
pub map_name: Option<String>,
|
||||
/// Summoner name.
|
||||
#[serde(default)]
|
||||
pub summoner_name: Option<String>,
|
||||
/// Team (100 = blue, 200 = red).
|
||||
#[serde(default)]
|
||||
pub team: Option<u32>,
|
||||
/// Whether the game was won.
|
||||
#[serde(default)]
|
||||
pub victory: Option<bool>,
|
||||
/// Final player stats.
|
||||
#[serde(default)]
|
||||
pub final_stats: Option<GameFinalStats>,
|
||||
}
|
||||
|
||||
impl Timeline {
|
||||
@@ -43,6 +75,16 @@ impl Timeline {
|
||||
end_time: None,
|
||||
duration_secs: 0,
|
||||
events: Vec::new(),
|
||||
champion: None,
|
||||
skin_name: None,
|
||||
queue_type: None,
|
||||
queue_id: None,
|
||||
game_mode: None,
|
||||
map_name: None,
|
||||
summoner_name: None,
|
||||
team: None,
|
||||
victory: None,
|
||||
final_stats: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,10 +180,13 @@ fn format_timestamp(duration: Duration) -> String {
|
||||
fn event_type_name(event: &GameEvent) -> String {
|
||||
match event {
|
||||
GameEvent::MatchFound(_) => "match_found",
|
||||
GameEvent::ChampSelectStart(_) => "champ_select_start",
|
||||
GameEvent::ChampionPick(_) => "champion_pick",
|
||||
GameEvent::GameStart(_) => "game_start",
|
||||
GameEvent::Kill(_) => "kill",
|
||||
GameEvent::Death(_) => "death",
|
||||
GameEvent::Objective(_) => "objective",
|
||||
GameEvent::StatsUpdate(_) => "stats_update",
|
||||
GameEvent::GameEnd(_) => "game_end",
|
||||
GameEvent::PhaseChange(_) => "phase_change",
|
||||
GameEvent::Unknown => "unknown",
|
||||
|
||||
@@ -38,8 +38,28 @@ pub struct RecordingMetadata {
|
||||
pub id: Uuid,
|
||||
/// Game ID if available.
|
||||
pub game_id: Option<u64>,
|
||||
/// Match ID if available.
|
||||
pub match_id: Option<String>,
|
||||
/// Champion played.
|
||||
pub champion: Option<String>,
|
||||
/// Skin name.
|
||||
pub skin_name: Option<String>,
|
||||
/// Queue type (ranked, normal, aram, etc.).
|
||||
pub queue_type: Option<String>,
|
||||
/// Queue ID.
|
||||
pub queue_id: Option<u32>,
|
||||
/// Game mode.
|
||||
pub game_mode: Option<String>,
|
||||
/// Map name.
|
||||
pub map_name: Option<String>,
|
||||
/// Player's summoner name.
|
||||
pub summoner_name: Option<String>,
|
||||
/// Team (100 = blue, 200 = red).
|
||||
pub team: Option<u32>,
|
||||
/// Whether the game was won.
|
||||
pub victory: Option<bool>,
|
||||
/// Final player stats.
|
||||
pub final_stats: Option<GameFinalStats>,
|
||||
/// Recording start time.
|
||||
pub start_time: DateTime<Utc>,
|
||||
/// Recording end time.
|
||||
@@ -48,7 +68,7 @@ pub struct RecordingMetadata {
|
||||
// #[serde(with = "chrono::serde::seconds")]
|
||||
pub duration: Duration,
|
||||
/// Output file path.
|
||||
pub file_path: PathBuf,
|
||||
pub file_path: Option<PathBuf>,
|
||||
/// File size in bytes.
|
||||
pub file_size: Option<u64>,
|
||||
/// Number of events.
|
||||
@@ -57,17 +77,66 @@ pub struct RecordingMetadata {
|
||||
pub finalized: bool,
|
||||
}
|
||||
|
||||
/// Final game statistics for the player.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GameFinalStats {
|
||||
/// Kills.
|
||||
pub kills: u32,
|
||||
/// Deaths.
|
||||
pub deaths: u32,
|
||||
/// Assists.
|
||||
pub assists: u32,
|
||||
/// Creep score.
|
||||
pub creep_score: u32,
|
||||
/// Gold earned.
|
||||
pub gold_earned: u32,
|
||||
/// Damage dealt.
|
||||
pub damage_dealt: u64,
|
||||
/// Damage taken.
|
||||
pub damage_taken: u64,
|
||||
/// Vision score.
|
||||
pub vision_score: f64,
|
||||
/// Game duration in seconds.
|
||||
pub game_duration: f64,
|
||||
}
|
||||
|
||||
/// Update for recording metadata.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MetadataUpdate {
|
||||
pub champion: Option<String>,
|
||||
pub match_id: Option<String>,
|
||||
pub skin_name: Option<String>,
|
||||
pub queue_type: Option<String>,
|
||||
pub queue_id: Option<u32>,
|
||||
pub game_mode: Option<String>,
|
||||
pub map_name: Option<String>,
|
||||
pub summoner_name: Option<String>,
|
||||
pub team: Option<u32>,
|
||||
pub victory: Option<bool>,
|
||||
pub final_stats: Option<GameFinalStats>,
|
||||
}
|
||||
|
||||
impl RecordingMetadata {
|
||||
/// Create metadata from a recording result.
|
||||
pub fn from_result(result: &RecordingResult) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
game_id: result.game_id,
|
||||
match_id: None,
|
||||
champion: result.champion.clone(),
|
||||
skin_name: None,
|
||||
queue_type: None,
|
||||
queue_id: None,
|
||||
game_mode: None,
|
||||
map_name: None,
|
||||
summoner_name: None,
|
||||
team: None,
|
||||
victory: None,
|
||||
final_stats: None,
|
||||
start_time: result.start_time,
|
||||
end_time: Some(result.end_time),
|
||||
duration: result.duration,
|
||||
file_path: result.path.clone(),
|
||||
file_path: Some(result.path.clone()),
|
||||
file_size: result.file_size(),
|
||||
event_count: 0,
|
||||
finalized: false,
|
||||
@@ -108,17 +177,84 @@ impl TimelineStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a new recording.
|
||||
/// Start a new recording entry (called when recording begins).
|
||||
/// Returns the recording ID for tracking events during recording.
|
||||
pub fn start_recording_entry(&self, game_id: Option<u64>, champion: Option<String>) -> Uuid {
|
||||
let id = Uuid::new_v4();
|
||||
let metadata = RecordingMetadata {
|
||||
id,
|
||||
game_id,
|
||||
match_id: None,
|
||||
champion,
|
||||
skin_name: None,
|
||||
queue_type: None,
|
||||
queue_id: None,
|
||||
game_mode: None,
|
||||
map_name: None,
|
||||
summoner_name: None,
|
||||
team: None,
|
||||
victory: None,
|
||||
final_stats: None,
|
||||
start_time: Utc::now(),
|
||||
end_time: None,
|
||||
duration: Duration::zero(),
|
||||
file_path: None,
|
||||
file_size: None,
|
||||
event_count: 0,
|
||||
finalized: false,
|
||||
};
|
||||
|
||||
self.recordings.write().insert(id, metadata);
|
||||
self.timelines.write().insert(id, Vec::new());
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
/// Finalize a recording with the recording result.
|
||||
/// Called when recording stops.
|
||||
pub fn finalize_recording(&self, recording_id: Uuid, result: RecordingResult) -> Result<()> {
|
||||
let mut recordings = self.recordings.write();
|
||||
|
||||
let metadata = recordings
|
||||
.get_mut(&recording_id)
|
||||
.ok_or(TimelineError::RecordingNotFound(recording_id))?;
|
||||
|
||||
// Update with final recording data
|
||||
metadata.start_time = result.start_time;
|
||||
metadata.end_time = Some(result.end_time);
|
||||
metadata.duration = result.duration;
|
||||
metadata.file_path = Some(result.path.clone());
|
||||
metadata.file_size = result.file_size();
|
||||
metadata.finalized = true;
|
||||
|
||||
// Persist to disk
|
||||
drop(recordings);
|
||||
self.persist_recording(recording_id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a new recording (legacy method for backwards compatibility).
|
||||
pub fn add_recording(&self, result: RecordingResult) -> Result<Uuid> {
|
||||
let id = Uuid::new_v4();
|
||||
let metadata = RecordingMetadata {
|
||||
id,
|
||||
game_id: result.game_id,
|
||||
match_id: None,
|
||||
champion: result.champion.clone(),
|
||||
skin_name: None,
|
||||
queue_type: None,
|
||||
queue_id: None,
|
||||
game_mode: None,
|
||||
map_name: None,
|
||||
summoner_name: None,
|
||||
team: None,
|
||||
victory: None,
|
||||
final_stats: None,
|
||||
start_time: result.start_time,
|
||||
end_time: Some(result.end_time),
|
||||
duration: result.duration,
|
||||
file_path: result.path.clone(),
|
||||
file_path: Some(result.path.clone()),
|
||||
file_size: result.file_size(),
|
||||
event_count: 0,
|
||||
finalized: true,
|
||||
@@ -153,6 +289,49 @@ impl TimelineStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update metadata for a recording.
|
||||
pub fn update_metadata(&self, recording_id: Uuid, update: MetadataUpdate) -> Result<()> {
|
||||
let mut recordings = self.recordings.write();
|
||||
if let Some(metadata) = recordings.get_mut(&recording_id) {
|
||||
if let Some(champion) = update.champion {
|
||||
metadata.champion = Some(champion);
|
||||
}
|
||||
if let Some(match_id) = update.match_id {
|
||||
metadata.match_id = Some(match_id);
|
||||
}
|
||||
if let Some(skin_name) = update.skin_name {
|
||||
metadata.skin_name = Some(skin_name);
|
||||
}
|
||||
if let Some(queue_type) = update.queue_type {
|
||||
metadata.queue_type = Some(queue_type);
|
||||
}
|
||||
if let Some(queue_id) = update.queue_id {
|
||||
metadata.queue_id = Some(queue_id);
|
||||
}
|
||||
if let Some(game_mode) = update.game_mode {
|
||||
metadata.game_mode = Some(game_mode);
|
||||
}
|
||||
if let Some(map_name) = update.map_name {
|
||||
metadata.map_name = Some(map_name);
|
||||
}
|
||||
if let Some(summoner_name) = update.summoner_name {
|
||||
metadata.summoner_name = Some(summoner_name);
|
||||
}
|
||||
if let Some(team) = update.team {
|
||||
metadata.team = Some(team);
|
||||
}
|
||||
if let Some(victory) = update.victory {
|
||||
metadata.victory = Some(victory);
|
||||
}
|
||||
if let Some(final_stats) = update.final_stats {
|
||||
metadata.final_stats = Some(final_stats);
|
||||
}
|
||||
}
|
||||
drop(recordings);
|
||||
self.persist_recording(recording_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all recordings.
|
||||
pub fn get_all_recordings(&self) -> Result<Vec<RecordingMetadata>> {
|
||||
let recordings = self.recordings.read();
|
||||
@@ -184,6 +363,16 @@ impl TimelineStore {
|
||||
end_time: metadata.end_time,
|
||||
duration_secs: metadata.duration.num_seconds(),
|
||||
events,
|
||||
champion: metadata.champion.clone(),
|
||||
skin_name: metadata.skin_name.clone(),
|
||||
queue_type: metadata.queue_type.clone(),
|
||||
queue_id: metadata.queue_id,
|
||||
game_mode: metadata.game_mode.clone(),
|
||||
map_name: metadata.map_name.clone(),
|
||||
summoner_name: metadata.summoner_name.clone(),
|
||||
team: metadata.team,
|
||||
victory: metadata.victory,
|
||||
final_stats: metadata.final_stats.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -220,6 +409,16 @@ impl TimelineStore {
|
||||
end_time: metadata.end_time,
|
||||
duration_secs: metadata.duration.num_seconds(),
|
||||
events,
|
||||
champion: metadata.champion,
|
||||
skin_name: metadata.skin_name,
|
||||
queue_type: metadata.queue_type,
|
||||
queue_id: metadata.queue_id,
|
||||
game_mode: metadata.game_mode,
|
||||
map_name: metadata.map_name,
|
||||
summoner_name: metadata.summoner_name,
|
||||
team: metadata.team,
|
||||
victory: metadata.victory,
|
||||
final_stats: metadata.final_stats,
|
||||
};
|
||||
|
||||
let file_path = self.storage_dir.join(format!("{}.json", id));
|
||||
@@ -246,11 +445,21 @@ impl TimelineStore {
|
||||
let metadata = RecordingMetadata {
|
||||
id: timeline.recording_id,
|
||||
game_id: None,
|
||||
match_id: None,
|
||||
champion: None,
|
||||
skin_name: None,
|
||||
queue_type: None,
|
||||
queue_id: None,
|
||||
game_mode: None,
|
||||
map_name: None,
|
||||
summoner_name: None,
|
||||
team: None,
|
||||
victory: None,
|
||||
final_stats: None,
|
||||
start_time: timeline.start_time,
|
||||
end_time: timeline.end_time,
|
||||
duration: timeline.duration(),
|
||||
file_path: PathBuf::new(),
|
||||
file_path: None,
|
||||
file_size: None,
|
||||
event_count: timeline.events.len(),
|
||||
finalized: true,
|
||||
|
||||
Reference in New Issue
Block a user