record-daemon: refactor and cleanup

This commit is contained in:
2026-03-21 00:05:46 +01:00
parent 1166424c29
commit 03e36d0f1b
12 changed files with 140 additions and 139 deletions

View File

@@ -90,6 +90,13 @@ pub struct OutputSettings {
pub split_time_minutes: u32, 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 { impl Default for OutputSettings {
fn default() -> Self { fn default() -> Self {
Self { Self {

View File

@@ -27,7 +27,7 @@ pub enum DaemonError {
Serialization(#[from] serde_json::Error), Serialization(#[from] serde_json::Error),
#[error("WebSocket error: {0}")] #[error("WebSocket error: {0}")]
WebSocket(#[from] tokio_tungstenite::tungstenite::Error), WebSocket(#[from] Box<tokio_tungstenite::tungstenite::Error>),
#[error("HTTP request error: {0}")] #[error("HTTP request error: {0}")]
Http(#[from] reqwest::Error), Http(#[from] reqwest::Error),

View File

@@ -6,7 +6,10 @@ use async_trait::async_trait;
use parking_lot::RwLock; use parking_lot::RwLock;
use tracing::{debug, info}; 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::config::Settings;
use crate::error::Result; use crate::error::Result;
use crate::recording::encoder::{detect_hardware_encoders, EncoderCapability}; use crate::recording::encoder::{detect_hardware_encoders, EncoderCapability};
@@ -94,7 +97,8 @@ impl IpcHandlers {
async fn handle_get_settings(&self) -> Result<serde_json::Value> { async fn handle_get_settings(&self) -> Result<serde_json::Value> {
let settings = self.settings.read().clone(); 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( async fn handle_update_settings(
@@ -142,12 +146,15 @@ impl IpcHandlers {
.map(|r| r.is_recording()) .map(|r| r.is_recording())
.unwrap_or(false); .unwrap_or(false);
let client_connected = *self.client_connected.read(); 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!({ let response = GetStatusResponse {
"status": status, status,
"isRecording": is_recording, is_recording,
"clientConnected": client_connected, current_game_id,
})) client_connected,
};
Ok(serde_json::to_value(response)?)
} }
async fn handle_get_encoders(&self) -> Result<serde_json::Value> { async fn handle_get_encoders(&self) -> Result<serde_json::Value> {
@@ -160,40 +167,38 @@ impl IpcHandlers {
.encoder_name() .encoder_name()
.to_string(); .to_string();
let encoders: Vec<_> = capabilities let available: Vec<EncoderInfo> = capabilities
.iter() .iter()
.map(|cap| match cap { .map(|cap| match cap {
EncoderCapability::Nvenc => serde_json::json!({ EncoderCapability::Nvenc => EncoderInfo {
"id": "jim_nvenc", id: "jim_nvenc".to_string(),
"name": "NVIDIA NVENC", name: "NVIDIA NVENC".to_string(),
"isHardware": true, is_hardware: true,
"available": true, available: true,
}), },
EncoderCapability::Amf => serde_json::json!({ EncoderCapability::Amf => EncoderInfo {
"id": "amd_amf_h264", id: "amd_amf_h264".to_string(),
"name": "AMD AMF", name: "AMD AMF".to_string(),
"isHardware": true, is_hardware: true,
"available": true, available: true,
}), },
EncoderCapability::QuickSync => serde_json::json!({ EncoderCapability::QuickSync => EncoderInfo {
"id": "qsv", id: "qsv".to_string(),
"name": "Intel QuickSync", name: "Intel QuickSync".to_string(),
"isHardware": true, is_hardware: true,
"available": true, available: true,
}), },
EncoderCapability::Software => serde_json::json!({ EncoderCapability::Software => EncoderInfo {
"id": "x264", id: "x264".to_string(),
"name": "x264 (Software)", name: "x264 (Software)".to_string(),
"isHardware": false, is_hardware: false,
"available": true, available: true,
}), },
}) })
.collect(); .collect();
Ok(serde_json::json!({ let response = GetEncodersResponse { available, current };
"available": encoders, Ok(serde_json::to_value(response)?)
"current": current,
}))
} }
async fn handle_start_recording(&self) -> Result<serde_json::Value> { async fn handle_start_recording(&self) -> Result<serde_json::Value> {
@@ -232,7 +237,8 @@ impl IpcHandlers {
async fn handle_get_recordings(&self) -> Result<serde_json::Value> { async fn handle_get_recordings(&self) -> Result<serde_json::Value> {
let recordings = self.timeline.read().get_all_recordings()?; 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<serde_json::Value> { async fn handle_get_recording(&self, id: uuid::Uuid) -> Result<serde_json::Value> {

View File

@@ -13,18 +13,10 @@ use crate::error::{LqpError, Result};
/// League Client lockfile location (relative to League Client install). /// League Client lockfile location (relative to League Client install).
pub const LOCKFILE_NAME: &str = "lockfile"; 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. /// Default League Client paths on Windows.
pub const WINDOWS_CLIENT_PATHS: &[&str] = &[ pub const WINDOWS_CLIENT_PATHS: &[&str] = &[
"C:\\Riot Games\\League of Legends\\lockfile", "C:\\Riot Games\\League of Legends",
"D:\\Riot Games\\League of Legends\\lockfile", "D:\\Riot Games\\League of Legends",
]; ];
/// Credentials extracted from the League Client lockfile. /// Credentials extracted from the League Client lockfile.
@@ -126,65 +118,18 @@ impl LockfileWatcher {
/// Discover possible lockfile paths based on OS. /// Discover possible lockfile paths based on OS.
fn discover_lockfile_paths() -> Vec<PathBuf> { fn discover_lockfile_paths() -> Vec<PathBuf> {
let mut paths = Vec::new(); // For now, League of Legends is not available on Linux.
// There is no point in trying to locate the client.
#[cfg(target_os = "linux")] if cfg!(target_os = "windows") {
{ WINDOWS_CLIENT_PATHS
// Check for Wine/Lutris installations .iter()
if let Some(home) = std::env::var_os("HOME") { .map(|s| PathBuf::from(s).join(LOCKFILE_NAME))
let home_path = PathBuf::from(&home); .collect()
} else {
// Lutris default Vec::new()
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);
} }
} }
#[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. /// Check if the League Client is currently running.
pub fn is_client_running(&self) -> bool { pub fn is_client_running(&self) -> bool {
self.current.is_some() self.current.is_some()

View File

@@ -10,7 +10,7 @@ use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::protocol::Me
use tracing::{debug, error, info, trace, warn}; use tracing::{debug, error, info, trace, warn};
use super::auth::LockfileCredentials; use super::auth::LockfileCredentials;
use super::events::GameEvent; use super::events::{GameEvent, RawEvent};
use crate::error::{LqpError, Result}; use crate::error::{LqpError, Result};
/// Custom certificate verifier that accepts any certificate. /// Custom certificate verifier that accepts any certificate.
@@ -385,7 +385,22 @@ impl LqpClient {
let event_data = arr.get(2)?; let event_data = arr.get(2)?;
if callback == "OnJsonApiEvent" { 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::<RawEvent>(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 uri = event_data.get("uri")?.as_str()?;
let data = event_data.get("data")?; let data = event_data.get("data")?;
let event_type = event_data let event_type = event_data
@@ -582,6 +597,16 @@ impl LqpClient {
pub async fn get_summoner(&self) -> Result<serde_json::Value> { pub async fn get_summoner(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::SUMMONER).await self.request("GET", endpoints::SUMMONER).await
} }
/// Get champion select session info.
pub async fn get_champion_select(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::CHAMPION_SELECT).await
}
/// Get end-of-game stats.
pub async fn get_game_stats(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::GAME_STATS).await
}
} }
impl Default for LqpClient { impl Default for LqpClient {

View File

@@ -9,7 +9,6 @@ use tokio::sync::broadcast;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use futures::StreamExt;
use record_daemon::{ use record_daemon::{
config::{self, Settings}, config::{self, Settings},
error::Result, error::Result,

View File

@@ -193,33 +193,6 @@ pub enum SourceType {
MonitorCapture, 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<String> {
#[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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -244,7 +217,7 @@ mod tests {
#[test] #[test]
fn test_league_capture_config() { 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.name, "League of Legends Capture");
assert_eq!( assert_eq!(
capture.process_name, capture.process_name,

View File

@@ -11,7 +11,7 @@ mod output;
pub use capture::{CaptureMode, GameCapture, MonitorCapture, SourceType, WindowCapture}; pub use capture::{CaptureMode, GameCapture, MonitorCapture, SourceType, WindowCapture};
pub use encoder::{AudioEncoderConfig, EncoderCapability, EncoderConfig, EncoderSettings}; pub use encoder::{AudioEncoderConfig, EncoderCapability, EncoderConfig, EncoderSettings};
pub use obs_context::{ObsContext, ObsContextBuilder}; pub use obs_context::{ObsContext, ObsContextBuilder};
pub use output::{OutputConfig, RecordingOutput, RecordingResult}; pub use output::{OutputConfig, OutputFormat, RecordingOutput, RecordingResult, RecordingStats};
use std::sync::Arc; use std::sync::Arc;
@@ -86,6 +86,11 @@ impl RecordingEngine {
self.is_recording self.is_recording
} }
/// Get the current game ID if recording.
pub fn current_game_id(&self) -> Option<u64> {
self.current_output.as_ref().and_then(|o| o.game_id)
}
/// Start recording. /// Start recording.
/// ///
/// # Arguments /// # Arguments
@@ -170,6 +175,7 @@ impl RecordingEngine {
start_time, start_time,
end_time, end_time,
duration, duration,
stats: None, // Stats would be populated from OBS context if available
}; };
info!("Recording stopped: {:?}", result.path); info!("Recording stopped: {:?}", result.path);

View File

@@ -129,6 +129,11 @@ impl ObsContext {
&self.video_settings &self.video_settings
} }
/// Get the output directory for recordings.
pub fn output_dir(&self) -> &std::path::Path {
&self.output_dir
}
/// Initialize OBS. /// Initialize OBS.
fn initialize(&mut self) -> Result<()> { fn initialize(&mut self) -> Result<()> {
if self.context.is_some() { if self.context.is_some() {

View File

@@ -59,6 +59,9 @@ pub struct RecordingResult {
/// Recording duration. /// Recording duration.
// #[serde(with = "chrono::serde::seconds")] // #[serde(with = "chrono::serde::seconds")]
pub duration: Duration, pub duration: Duration,
/// Recording statistics (if available).
#[serde(skip_serializing_if = "Option::is_none")]
pub stats: Option<RecordingStats>,
} }
impl RecordingResult { impl RecordingResult {
@@ -209,6 +212,7 @@ mod tests {
start_time: Utc::now(), start_time: Utc::now(),
end_time: Utc::now() + Duration::seconds(125), end_time: Utc::now() + Duration::seconds(125),
duration: Duration::seconds(125), duration: Duration::seconds(125),
stats: None,
}; };
assert_eq!(result.duration_human(), "2m 5s"); assert_eq!(result.duration_human(), "2m 5s");

View File

@@ -11,6 +11,8 @@ pub struct EventMapper {
start_time: Option<DateTime<Utc>>, start_time: Option<DateTime<Utc>>,
/// Game start time (from game event). /// Game start time (from game event).
game_start_time: Option<DateTime<Utc>>, game_start_time: Option<DateTime<Utc>>,
/// Synchronizer for handling time drift.
synchronizer: EventSynchronizer,
} }
impl EventMapper { impl EventMapper {
@@ -19,12 +21,14 @@ impl EventMapper {
Self { Self {
start_time: None, start_time: None,
game_start_time: None, game_start_time: None,
synchronizer: EventSynchronizer::new(),
} }
} }
/// Start the mapper (recording started). /// Start the mapper (recording started).
pub fn start(&mut self) { pub fn start(&mut self) {
self.start_time = Some(Utc::now()); self.start_time = Some(Utc::now());
self.synchronizer.reset();
debug!("Event mapper started at {:?}", self.start_time); debug!("Event mapper started at {:?}", self.start_time);
} }
@@ -32,6 +36,7 @@ impl EventMapper {
pub fn stop(&mut self) { pub fn stop(&mut self) {
self.start_time = None; self.start_time = None;
self.game_start_time = None; self.game_start_time = None;
self.synchronizer.reset();
debug!("Event mapper stopped"); debug!("Event mapper stopped");
} }
@@ -49,7 +54,11 @@ impl EventMapper {
let video_timestamp = now - start_time; let video_timestamp = now - start_time;
// Calculate game timestamp if we have game 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 // Update game start time if this is a game start event
// (handled separately in handle_event) // (handled separately in handle_event)
@@ -69,7 +78,14 @@ impl EventMapper {
debug!("Game start time recorded: {:?}", self.game_start_time); 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. /// Get the current video timestamp.
@@ -79,7 +95,10 @@ impl EventMapper {
/// Get the current game timestamp. /// Get the current game timestamp.
pub fn current_game_timestamp(&self) -> Option<Duration> { pub fn current_game_timestamp(&self) -> Option<Duration> {
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. /// Get the recording duration so far.
@@ -87,10 +106,21 @@ impl EventMapper {
self.current_video_timestamp() 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<Duration> {
self.synchronizer.calculate_drift()
}
/// Reset the mapper. /// Reset the mapper.
pub fn reset(&mut self) { pub fn reset(&mut self) {
self.start_time = None; self.start_time = None;
self.game_start_time = None; self.game_start_time = None;
self.synchronizer.reset();
debug!("Event mapper reset"); debug!("Event mapper reset");
} }
} }

View File

@@ -299,6 +299,7 @@ mod tests {
start_time: Utc::now(), start_time: Utc::now(),
end_time: Utc::now() + Duration::seconds(60), end_time: Utc::now() + Duration::seconds(60),
duration: Duration::seconds(60), duration: Duration::seconds(60),
stats: None,
}; };
let id = store.add_recording(result).unwrap(); let id = store.add_recording(result).unwrap();