record-daemon: refactor and cleanup
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<tokio_tungstenite::tungstenite::Error>),
|
||||
|
||||
#[error("HTTP request error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
|
||||
@@ -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<serde_json::Value> {
|
||||
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<serde_json::Value> {
|
||||
@@ -160,40 +167,38 @@ impl IpcHandlers {
|
||||
.encoder_name()
|
||||
.to_string();
|
||||
|
||||
let encoders: Vec<_> = capabilities
|
||||
let available: Vec<EncoderInfo> = 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<serde_json::Value> {
|
||||
@@ -232,7 +237,8 @@ impl IpcHandlers {
|
||||
|
||||
async fn handle_get_recordings(&self) -> Result<serde_json::Value> {
|
||||
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> {
|
||||
|
||||
@@ -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,65 +118,18 @@ impl LockfileWatcher {
|
||||
|
||||
/// Discover possible lockfile paths based on OS.
|
||||
fn discover_lockfile_paths() -> Vec<PathBuf> {
|
||||
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.
|
||||
pub fn is_client_running(&self) -> bool {
|
||||
self.current.is_some()
|
||||
|
||||
@@ -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::<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 data = event_data.get("data")?;
|
||||
let event_type = event_data
|
||||
@@ -582,6 +597,16 @@ impl LqpClient {
|
||||
pub async fn get_summoner(&self) -> Result<serde_json::Value> {
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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)]
|
||||
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,
|
||||
|
||||
@@ -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<u64> {
|
||||
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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<RecordingStats>,
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
@@ -11,6 +11,8 @@ pub struct EventMapper {
|
||||
start_time: Option<DateTime<Utc>>,
|
||||
/// Game start time (from game event).
|
||||
game_start_time: Option<DateTime<Utc>>,
|
||||
/// 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<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.
|
||||
@@ -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<Duration> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user