record-daemon: initial commit

This commit is contained in:
2026-03-19 17:48:07 +01:00
commit d6c0334369
30 changed files with 9486 additions and 0 deletions

View 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"));
}
}