From 03e36d0f1bfee30566d23e806a5ffee8a8ead9e8 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sat, 21 Mar 2026 00:05:46 +0100 Subject: [PATCH] record-daemon: refactor and cleanup --- record-daemon/src/config/settings.rs | 7 ++ record-daemon/src/error.rs | 2 +- record-daemon/src/ipc/handlers.rs | 80 ++++++++++++---------- record-daemon/src/lqp/auth.rs | 77 +++------------------ record-daemon/src/lqp/client.rs | 29 +++++++- record-daemon/src/main.rs | 1 - record-daemon/src/recording/capture.rs | 29 +------- record-daemon/src/recording/mod.rs | 8 ++- record-daemon/src/recording/obs_context.rs | 5 ++ record-daemon/src/recording/output.rs | 4 ++ record-daemon/src/timeline/mapper.rs | 36 +++++++++- record-daemon/src/timeline/store.rs | 1 + 12 files changed, 140 insertions(+), 139 deletions(-) diff --git a/record-daemon/src/config/settings.rs b/record-daemon/src/config/settings.rs index acc8f8b..50e44c4 100644 --- a/record-daemon/src/config/settings.rs +++ b/record-daemon/src/config/settings.rs @@ -90,6 +90,13 @@ pub struct OutputSettings { pub split_time_minutes: u32, } +impl OutputSettings { + /// Get the output format enum for this container. + pub fn output_format(&self) -> crate::recording::OutputFormat { + crate::recording::OutputFormat::from(self.container.as_str()) + } +} + impl Default for OutputSettings { fn default() -> Self { Self { diff --git a/record-daemon/src/error.rs b/record-daemon/src/error.rs index 81eff3a..4cb6670 100644 --- a/record-daemon/src/error.rs +++ b/record-daemon/src/error.rs @@ -27,7 +27,7 @@ pub enum DaemonError { Serialization(#[from] serde_json::Error), #[error("WebSocket error: {0}")] - WebSocket(#[from] tokio_tungstenite::tungstenite::Error), + WebSocket(#[from] Box), #[error("HTTP request error: {0}")] Http(#[from] reqwest::Error), diff --git a/record-daemon/src/ipc/handlers.rs b/record-daemon/src/ipc/handlers.rs index f34cc2a..025d815 100644 --- a/record-daemon/src/ipc/handlers.rs +++ b/record-daemon/src/ipc/handlers.rs @@ -6,7 +6,10 @@ use async_trait::async_trait; use parking_lot::RwLock; use tracing::{debug, info}; -use super::protocol::{IpcCommand, IpcMessage, IpcResponse}; +use super::protocol::{ + EncoderInfo, GetEncodersResponse, GetRecordingsResponse, GetSettingsResponse, + GetStatusResponse, IpcCommand, IpcMessage, IpcResponse, +}; use crate::config::Settings; use crate::error::Result; use crate::recording::encoder::{detect_hardware_encoders, EncoderCapability}; @@ -94,7 +97,8 @@ impl IpcHandlers { async fn handle_get_settings(&self) -> Result { let settings = self.settings.read().clone(); - Ok(serde_json::to_value(settings)?) + let response = GetSettingsResponse { settings }; + Ok(serde_json::to_value(response)?) } async fn handle_update_settings( @@ -142,12 +146,15 @@ impl IpcHandlers { .map(|r| r.is_recording()) .unwrap_or(false); let client_connected = *self.client_connected.read(); + let current_game_id = recording.as_ref().and_then(|r| r.current_game_id()); - Ok(serde_json::json!({ - "status": status, - "isRecording": is_recording, - "clientConnected": client_connected, - })) + let response = GetStatusResponse { + status, + is_recording, + current_game_id, + client_connected, + }; + Ok(serde_json::to_value(response)?) } async fn handle_get_encoders(&self) -> Result { @@ -160,40 +167,38 @@ impl IpcHandlers { .encoder_name() .to_string(); - let encoders: Vec<_> = capabilities + let available: Vec = capabilities .iter() .map(|cap| match cap { - EncoderCapability::Nvenc => serde_json::json!({ - "id": "jim_nvenc", - "name": "NVIDIA NVENC", - "isHardware": true, - "available": true, - }), - EncoderCapability::Amf => serde_json::json!({ - "id": "amd_amf_h264", - "name": "AMD AMF", - "isHardware": true, - "available": true, - }), - EncoderCapability::QuickSync => serde_json::json!({ - "id": "qsv", - "name": "Intel QuickSync", - "isHardware": true, - "available": true, - }), - EncoderCapability::Software => serde_json::json!({ - "id": "x264", - "name": "x264 (Software)", - "isHardware": false, - "available": true, - }), + EncoderCapability::Nvenc => EncoderInfo { + id: "jim_nvenc".to_string(), + name: "NVIDIA NVENC".to_string(), + is_hardware: true, + available: true, + }, + EncoderCapability::Amf => EncoderInfo { + id: "amd_amf_h264".to_string(), + name: "AMD AMF".to_string(), + is_hardware: true, + available: true, + }, + EncoderCapability::QuickSync => EncoderInfo { + id: "qsv".to_string(), + name: "Intel QuickSync".to_string(), + is_hardware: true, + available: true, + }, + EncoderCapability::Software => EncoderInfo { + id: "x264".to_string(), + name: "x264 (Software)".to_string(), + is_hardware: false, + available: true, + }, }) .collect(); - Ok(serde_json::json!({ - "available": encoders, - "current": current, - })) + let response = GetEncodersResponse { available, current }; + Ok(serde_json::to_value(response)?) } async fn handle_start_recording(&self) -> Result { @@ -232,7 +237,8 @@ impl IpcHandlers { async fn handle_get_recordings(&self) -> Result { let recordings = self.timeline.read().get_all_recordings()?; - Ok(serde_json::to_value(recordings)?) + let response = GetRecordingsResponse { recordings }; + Ok(serde_json::to_value(response)?) } async fn handle_get_recording(&self, id: uuid::Uuid) -> Result { diff --git a/record-daemon/src/lqp/auth.rs b/record-daemon/src/lqp/auth.rs index 8549c9a..be8345f 100644 --- a/record-daemon/src/lqp/auth.rs +++ b/record-daemon/src/lqp/auth.rs @@ -13,18 +13,10 @@ use crate::error::{LqpError, Result}; /// League Client lockfile location (relative to League Client install). pub const LOCKFILE_NAME: &str = "lockfile"; -/// Default League Client paths on Linux (via Lutris/Wine). -pub const LINUX_CLIENT_PATHS: &[&str] = &[ - // Lutris default path - "$HOME/Games/League of Legends/LeagueClient", - // Wine prefix - "$HOME/.wine/drive_c/Riot Games/League of Legends/LeagueClient", -]; - /// Default League Client paths on Windows. pub const WINDOWS_CLIENT_PATHS: &[&str] = &[ - "C:\\Riot Games\\League of Legends\\lockfile", - "D:\\Riot Games\\League of Legends\\lockfile", + "C:\\Riot Games\\League of Legends", + "D:\\Riot Games\\League of Legends", ]; /// Credentials extracted from the League Client lockfile. @@ -126,63 +118,16 @@ impl LockfileWatcher { /// Discover possible lockfile paths based on OS. fn discover_lockfile_paths() -> Vec { - let mut paths = Vec::new(); - - #[cfg(target_os = "linux")] - { - // Check for Wine/Lutris installations - if let Some(home) = std::env::var_os("HOME") { - let home_path = PathBuf::from(&home); - - // Lutris default - let lutris_path = home_path - .join("Games") - .join("League of Legends") - .join("lockfile"); - paths.push(lutris_path); - - // Wine prefix - let wine_path = home_path - .join(".wine") - .join("drive_c") - .join("Riot Games") - .join("League of Legends") - .join("lockfile"); - paths.push(wine_path); - - // Proton (Steam) - let proton_path = home_path - .join(".local") - .join("share") - .join("Steam") - .join("steamapps") - .join("compatdata") - .join("League of Legends") - .join("pfx") - .join("drive_c") - .join("Riot Games") - .join("League of Legends") - .join("lockfile"); - paths.push(proton_path); - } + // For now, League of Legends is not available on Linux. + // There is no point in trying to locate the client. + if cfg!(target_os = "windows") { + WINDOWS_CLIENT_PATHS + .iter() + .map(|s| PathBuf::from(s).join(LOCKFILE_NAME)) + .collect() + } else { + Vec::new() } - - #[cfg(target_os = "windows")] - { - // Default Windows paths - paths.push(PathBuf::from("C:\\Riot Games\\League of Legends\\lockfile")); - paths.push(PathBuf::from("D:\\Riot Games\\League of Legends\\lockfile")); - - // Check Program Files - paths.push(PathBuf::from( - "C:\\Program Files\\Riot Games\\League of Legends\\lockfile", - )); - paths.push(PathBuf::from( - "C:\\Program Files (x86)\\Riot Games\\League of Legends\\lockfile", - )); - } - - paths } /// Check if the League Client is currently running. diff --git a/record-daemon/src/lqp/client.rs b/record-daemon/src/lqp/client.rs index cd5247b..ab49785 100644 --- a/record-daemon/src/lqp/client.rs +++ b/record-daemon/src/lqp/client.rs @@ -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; +use super::events::{GameEvent, RawEvent}; use crate::error::{LqpError, Result}; /// Custom certificate verifier that accepts any certificate. @@ -385,7 +385,22 @@ impl LqpClient { let event_data = arr.get(2)?; if callback == "OnJsonApiEvent" { - // Extract the actual URI and data from the event + // Try to parse as RawEvent for type-safe access + if let Ok(raw_event) = + serde_json::from_value::(event_data.clone()) + { + let event_type = event_data + .get("eventType") + .and_then(|t| t.as_str()) + .unwrap_or("Update"); + return Self::parse_event_from_uri( + &raw_event.uri, + event_type, + &serde_json::to_value(raw_event.data).unwrap_or_default(), + ); + } + + // Fallback to manual extraction let uri = event_data.get("uri")?.as_str()?; let data = event_data.get("data")?; let event_type = event_data @@ -582,6 +597,16 @@ impl LqpClient { pub async fn get_summoner(&self) -> Result { self.request("GET", endpoints::SUMMONER).await } + + /// Get champion select session info. + pub async fn get_champion_select(&self) -> Result { + self.request("GET", endpoints::CHAMPION_SELECT).await + } + + /// Get end-of-game stats. + pub async fn get_game_stats(&self) -> Result { + self.request("GET", endpoints::GAME_STATS).await + } } impl Default for LqpClient { diff --git a/record-daemon/src/main.rs b/record-daemon/src/main.rs index 663a6bc..db2574f 100644 --- a/record-daemon/src/main.rs +++ b/record-daemon/src/main.rs @@ -9,7 +9,6 @@ use tokio::sync::broadcast; use tracing::{debug, error, info, warn}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use futures::StreamExt; use record_daemon::{ config::{self, Settings}, error::Result, diff --git a/record-daemon/src/recording/capture.rs b/record-daemon/src/recording/capture.rs index 073ec99..b28681e 100644 --- a/record-daemon/src/recording/capture.rs +++ b/record-daemon/src/recording/capture.rs @@ -193,33 +193,6 @@ pub enum SourceType { MonitorCapture, } -/// Find the League of Legends game window. -/// -/// Returns the window title if found, or a default if not found. -pub fn find_league_window() -> Option { - #[cfg(target_os = "windows")] - { - // On Windows, we can use the Win32 API to find the window - // For now, return the expected window title - Some("League of Legends (TM) Client".to_string()) - } - - #[cfg(target_os = "linux")] - { - // On Linux, we would use X11 or Wayland APIs - // For now, return the expected window title - Some("League of Legends (TM) Client".to_string()) - } - - #[cfg(not(any(target_os = "linux", target_os = "windows")))] - None -} - -/// Get the default capture configuration for League of Legends. -pub fn league_capture_config() -> GameCapture { - GameCapture::for_league_of_legends() -} - #[cfg(test)] mod tests { use super::*; @@ -244,7 +217,7 @@ mod tests { #[test] fn test_league_capture_config() { - let capture = league_capture_config(); + let capture = GameCapture::for_league_of_legends(); assert_eq!(capture.name, "League of Legends Capture"); assert_eq!( capture.process_name, diff --git a/record-daemon/src/recording/mod.rs b/record-daemon/src/recording/mod.rs index 691dafd..2f86397 100644 --- a/record-daemon/src/recording/mod.rs +++ b/record-daemon/src/recording/mod.rs @@ -11,7 +11,7 @@ mod output; pub use capture::{CaptureMode, GameCapture, MonitorCapture, SourceType, WindowCapture}; pub use encoder::{AudioEncoderConfig, EncoderCapability, EncoderConfig, EncoderSettings}; pub use obs_context::{ObsContext, ObsContextBuilder}; -pub use output::{OutputConfig, RecordingOutput, RecordingResult}; +pub use output::{OutputConfig, OutputFormat, RecordingOutput, RecordingResult, RecordingStats}; use std::sync::Arc; @@ -86,6 +86,11 @@ impl RecordingEngine { self.is_recording } + /// Get the current game ID if recording. + pub fn current_game_id(&self) -> Option { + self.current_output.as_ref().and_then(|o| o.game_id) + } + /// Start recording. /// /// # Arguments @@ -170,6 +175,7 @@ impl RecordingEngine { start_time, end_time, duration, + stats: None, // Stats would be populated from OBS context if available }; info!("Recording stopped: {:?}", result.path); diff --git a/record-daemon/src/recording/obs_context.rs b/record-daemon/src/recording/obs_context.rs index 905fbc3..79d96fa 100644 --- a/record-daemon/src/recording/obs_context.rs +++ b/record-daemon/src/recording/obs_context.rs @@ -129,6 +129,11 @@ impl ObsContext { &self.video_settings } + /// Get the output directory for recordings. + pub fn output_dir(&self) -> &std::path::Path { + &self.output_dir + } + /// Initialize OBS. fn initialize(&mut self) -> Result<()> { if self.context.is_some() { diff --git a/record-daemon/src/recording/output.rs b/record-daemon/src/recording/output.rs index 6b98c02..9938807 100644 --- a/record-daemon/src/recording/output.rs +++ b/record-daemon/src/recording/output.rs @@ -59,6 +59,9 @@ pub struct RecordingResult { /// Recording duration. // #[serde(with = "chrono::serde::seconds")] pub duration: Duration, + /// Recording statistics (if available). + #[serde(skip_serializing_if = "Option::is_none")] + pub stats: Option, } impl RecordingResult { @@ -209,6 +212,7 @@ mod tests { start_time: Utc::now(), end_time: Utc::now() + Duration::seconds(125), duration: Duration::seconds(125), + stats: None, }; assert_eq!(result.duration_human(), "2m 5s"); diff --git a/record-daemon/src/timeline/mapper.rs b/record-daemon/src/timeline/mapper.rs index 10b2c97..09ff907 100644 --- a/record-daemon/src/timeline/mapper.rs +++ b/record-daemon/src/timeline/mapper.rs @@ -11,6 +11,8 @@ pub struct EventMapper { start_time: Option>, /// Game start time (from game event). game_start_time: Option>, + /// Synchronizer for handling time drift. + synchronizer: EventSynchronizer, } impl EventMapper { @@ -19,12 +21,14 @@ impl EventMapper { Self { start_time: None, game_start_time: None, + synchronizer: EventSynchronizer::new(), } } /// Start the mapper (recording started). pub fn start(&mut self) { self.start_time = Some(Utc::now()); + self.synchronizer.reset(); debug!("Event mapper started at {:?}", self.start_time); } @@ -32,6 +36,7 @@ impl EventMapper { pub fn stop(&mut self) { self.start_time = None; self.game_start_time = None; + self.synchronizer.reset(); debug!("Event mapper stopped"); } @@ -49,7 +54,11 @@ impl EventMapper { let video_timestamp = now - start_time; // Calculate game timestamp if we have game start time - let game_timestamp = self.game_start_time.map(|game_start| now - game_start); + let game_timestamp = self.game_start_time.map(|game_start| { + let raw_ts = now - game_start; + // Apply drift correction if synchronizer has data + self.synchronizer.adjust_game_timestamp(raw_ts) + }); // Update game start time if this is a game start event // (handled separately in handle_event) @@ -69,7 +78,14 @@ impl EventMapper { debug!("Game start time recorded: {:?}", self.game_start_time); } - self.map_event(event) + let result = self.map_event(event); + + // Add sync point if we have both timestamps + if let Some((video_ts, Some(game_ts))) = result { + self.synchronizer.add_sync_point(video_ts, game_ts); + } + + result } /// Get the current video timestamp. @@ -79,7 +95,10 @@ impl EventMapper { /// Get the current game timestamp. pub fn current_game_timestamp(&self) -> Option { - self.game_start_time.map(|start| Utc::now() - start) + self.game_start_time.map(|start| { + let raw_ts = Utc::now() - start; + self.synchronizer.adjust_game_timestamp(raw_ts) + }) } /// Get the recording duration so far. @@ -87,10 +106,21 @@ impl EventMapper { self.current_video_timestamp() } + /// Check if time drift is within acceptable bounds. + pub fn is_time_sync_healthy(&self) -> bool { + self.synchronizer.is_drift_acceptable() + } + + /// Get the current time drift, if any. + pub fn time_drift(&self) -> Option { + self.synchronizer.calculate_drift() + } + /// Reset the mapper. pub fn reset(&mut self) { self.start_time = None; self.game_start_time = None; + self.synchronizer.reset(); debug!("Event mapper reset"); } } diff --git a/record-daemon/src/timeline/store.rs b/record-daemon/src/timeline/store.rs index 4569f32..f617b1d 100644 --- a/record-daemon/src/timeline/store.rs +++ b/record-daemon/src/timeline/store.rs @@ -299,6 +299,7 @@ mod tests { start_time: Utc::now(), end_time: Utc::now() + Duration::seconds(60), duration: Duration::seconds(60), + stats: None, }; let id = store.add_recording(result).unwrap();