record-daemon: initial commit
This commit is contained in:
241
record-daemon/src/recording/mod.rs
Normal file
241
record-daemon/src/recording/mod.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
//! Recording module for video capture using libobs.
|
||||
//!
|
||||
//! This module provides the recording engine that captures game footage
|
||||
//! and encodes it using hardware or software encoders.
|
||||
|
||||
mod capture;
|
||||
pub mod encoder;
|
||||
mod obs_context;
|
||||
mod output;
|
||||
|
||||
pub use capture::{CaptureSource, GameCapture};
|
||||
pub use encoder::{AudioEncoder, EncoderConfig, VideoEncoder};
|
||||
pub use obs_context::{ObsContext, ObsContextBuilder};
|
||||
pub use output::{OutputConfig, RecordingOutput, RecordingResult};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use parking_lot::RwLock;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Settings;
|
||||
use crate::error::{RecordingError, Result};
|
||||
|
||||
/// Recording engine that manages the entire recording pipeline.
|
||||
pub struct RecordingEngine {
|
||||
/// OBS context.
|
||||
context: Option<ObsContext>,
|
||||
/// Current settings.
|
||||
settings: Arc<RwLock<Settings>>,
|
||||
/// Current recording output.
|
||||
current_output: Option<RecordingOutput>,
|
||||
/// Recording start time.
|
||||
start_time: Option<DateTime<Utc>>,
|
||||
/// Whether recording is active.
|
||||
is_recording: bool,
|
||||
}
|
||||
|
||||
impl RecordingEngine {
|
||||
/// Create a new recording engine.
|
||||
pub fn new(settings: Settings) -> Self {
|
||||
Self {
|
||||
context: None,
|
||||
settings: Arc::new(RwLock::new(settings)),
|
||||
current_output: None,
|
||||
start_time: None,
|
||||
is_recording: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the recording engine.
|
||||
///
|
||||
/// This sets up OBS and prepares for recording.
|
||||
pub fn initialize(&mut self) -> Result<()> {
|
||||
info!("Initializing recording engine");
|
||||
|
||||
let settings = self.settings.read().clone();
|
||||
|
||||
// Build OBS context
|
||||
let context = ObsContextBuilder::new()
|
||||
.with_video_settings(settings.video.clone())
|
||||
.with_audio_settings(settings.audio.clone())
|
||||
.with_output_dir(&settings.output.path)
|
||||
.build()?;
|
||||
|
||||
self.context = Some(context);
|
||||
info!("Recording engine initialized successfully");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update settings.
|
||||
pub fn update_settings(&self, new_settings: Settings) -> Result<()> {
|
||||
let mut settings = self.settings.write();
|
||||
*settings = new_settings;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current settings.
|
||||
pub fn settings(&self) -> Settings {
|
||||
self.settings.read().clone()
|
||||
}
|
||||
|
||||
/// Check if currently recording.
|
||||
pub fn is_recording(&self) -> bool {
|
||||
self.is_recording
|
||||
}
|
||||
|
||||
/// Start recording.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `game_id` - Optional game ID for the recording.
|
||||
/// * `champion` - Optional champion name for the filename.
|
||||
pub fn start_recording(&mut self, game_id: Option<u64>, champion: Option<&str>) -> Result<()> {
|
||||
if self.is_recording {
|
||||
return Err(RecordingError::AlreadyRecording.into());
|
||||
}
|
||||
|
||||
let settings = self.settings.read().clone();
|
||||
|
||||
// Generate output filename
|
||||
let filename = self.generate_filename(game_id, champion);
|
||||
let output_path = settings.output.path.join(&filename);
|
||||
|
||||
info!("Starting recording to: {:?}", output_path);
|
||||
|
||||
// Start the recording
|
||||
let context = self.context.as_mut().ok_or(RecordingError::ObsInitError(
|
||||
"OBS not initialized".to_string(),
|
||||
))?;
|
||||
context.start_recording(&output_path)?;
|
||||
|
||||
self.current_output = Some(RecordingOutput {
|
||||
path: output_path,
|
||||
game_id,
|
||||
champion: champion.map(String::from),
|
||||
});
|
||||
self.start_time = Some(Utc::now());
|
||||
self.is_recording = true;
|
||||
|
||||
info!("Recording started successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop recording.
|
||||
pub fn stop_recording(&mut self) -> Result<RecordingResult> {
|
||||
if !self.is_recording {
|
||||
return Err(RecordingError::NotRecording.into());
|
||||
}
|
||||
|
||||
let context = self.context.as_mut().ok_or(RecordingError::ObsInitError(
|
||||
"OBS not initialized".to_string(),
|
||||
))?;
|
||||
|
||||
info!("Stopping recording");
|
||||
|
||||
// Stop the recording
|
||||
context.stop_recording()?;
|
||||
|
||||
let output = self
|
||||
.current_output
|
||||
.take()
|
||||
.ok_or(RecordingError::StopError("No active output".to_string()))?;
|
||||
|
||||
let start_time = self
|
||||
.start_time
|
||||
.take()
|
||||
.ok_or(RecordingError::StopError("No start time".to_string()))?;
|
||||
|
||||
let end_time = Utc::now();
|
||||
let duration = end_time - start_time;
|
||||
|
||||
self.is_recording = false;
|
||||
|
||||
let result = RecordingResult {
|
||||
path: output.path,
|
||||
game_id: output.game_id,
|
||||
champion: output.champion,
|
||||
start_time,
|
||||
end_time,
|
||||
duration,
|
||||
};
|
||||
|
||||
info!("Recording stopped: {:?}", result.path);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Get the current recording timestamp (time since recording started).
|
||||
pub fn current_timestamp(&self) -> Option<chrono::Duration> {
|
||||
if !self.is_recording {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.start_time.map(|start| Utc::now() - start)
|
||||
}
|
||||
|
||||
/// Generate a filename for the recording.
|
||||
fn generate_filename(&self, game_id: Option<u64>, champion: Option<&str>) -> String {
|
||||
let settings = self.settings.read();
|
||||
let now = Utc::now();
|
||||
|
||||
let date = now.format("%Y-%m-%d").to_string();
|
||||
let time = now.format("%H-%M-%S").to_string();
|
||||
let game_id_str = game_id.map(|id| id.to_string()).unwrap_or_default();
|
||||
let champion_str = champion.unwrap_or("unknown");
|
||||
|
||||
let filename = settings
|
||||
.output
|
||||
.naming_pattern
|
||||
.replace("{date}", &date)
|
||||
.replace("{time}", &time)
|
||||
.replace("{game_id}", &game_id_str)
|
||||
.replace("{champion}", champion_str);
|
||||
|
||||
format!("{}.{}", filename, settings.output.container)
|
||||
}
|
||||
|
||||
/// Shutdown the recording engine.
|
||||
pub fn shutdown(&mut self) -> Result<()> {
|
||||
if self.is_recording {
|
||||
self.stop_recording()?;
|
||||
}
|
||||
|
||||
if let Some(mut context) = self.context.take() {
|
||||
context.shutdown()?;
|
||||
}
|
||||
|
||||
info!("Recording engine shut down");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RecordingEngine {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = self.shutdown() {
|
||||
warn!("Error shutting down recording engine: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_recording_engine_creation() {
|
||||
let settings = Settings::default();
|
||||
let engine = RecordingEngine::new(settings);
|
||||
assert!(!engine.is_recording());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filename_generation() {
|
||||
let settings = Settings::default();
|
||||
let engine = RecordingEngine::new(settings);
|
||||
|
||||
let filename = engine.generate_filename(Some(12345), Some("Ahri"));
|
||||
assert!(filename.ends_with(".mp4"));
|
||||
assert!(filename.contains("Ahri"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user