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
+228
View File
@@ -0,0 +1,228 @@
//! Recording output configuration and results.
use std::path::PathBuf;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
/// Output configuration for recordings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputConfig {
/// Output container format.
pub container: String,
/// Output file path pattern.
pub path_pattern: String,
/// Maximum file size before splitting (MB). 0 = no limit.
pub max_size_mb: u32,
/// Maximum duration before splitting (seconds). 0 = no limit.
pub max_duration_secs: u32,
/// Whether to use fragmented MP4 for better crash recovery.
pub fragmented: bool,
}
impl Default for OutputConfig {
fn default() -> Self {
Self {
container: "mp4".to_string(),
path_pattern: "{date}_{time}".to_string(),
max_size_mb: 0,
max_duration_secs: 0,
fragmented: false,
}
}
}
/// Recording output handle.
#[derive(Debug, Clone)]
pub struct RecordingOutput {
/// Output file path.
pub path: PathBuf,
/// Game ID if available.
pub game_id: Option<u64>,
/// Champion name if available.
pub champion: Option<String>,
}
/// Result of a completed recording.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordingResult {
/// Output file path.
pub path: PathBuf,
/// Game ID if available.
pub game_id: Option<u64>,
/// Champion name if available.
pub champion: Option<String>,
/// Recording start time.
pub start_time: DateTime<Utc>,
/// Recording end time.
pub end_time: DateTime<Utc>,
/// Recording duration.
// #[serde(with = "chrono::serde::seconds")]
pub duration: Duration,
}
impl RecordingResult {
/// Get the file size in bytes.
pub fn file_size(&self) -> Option<u64> {
std::fs::metadata(&self.path).ok().map(|m| m.len())
}
/// Get the file size in a human-readable format.
pub fn file_size_human(&self) -> String {
let bytes = self.file_size().unwrap_or(0);
let mb = bytes as f64 / (1024.0 * 1024.0);
format!("{:.2} MB", mb)
}
/// Get the duration in a human-readable format.
pub fn duration_human(&self) -> String {
let total_secs = self.duration.num_seconds();
let hours = total_secs / 3600;
let mins = (total_secs % 3600) / 60;
let secs = total_secs % 60;
if hours > 0 {
format!("{}h {}m {}s", hours, mins, secs)
} else if mins > 0 {
format!("{}m {}s", mins, secs)
} else {
format!("{}s", secs)
}
}
/// Check if the recording file exists.
pub fn exists(&self) -> bool {
self.path.exists()
}
/// Delete the recording file.
pub fn delete(&self) -> std::io::Result<()> {
std::fs::remove_file(&self.path)
}
}
/// Output format type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
/// MP4 container.
Mp4,
/// MKV container.
Mkv,
/// MOV container.
Mov,
/// FLV container.
Flv,
}
impl OutputFormat {
/// Get the file extension.
pub fn extension(&self) -> &'static str {
match self {
OutputFormat::Mp4 => "mp4",
OutputFormat::Mkv => "mkv",
OutputFormat::Mov => "mov",
OutputFormat::Flv => "flv",
}
}
/// Get the OBS output ID.
pub fn obs_output_id(&self) -> &'static str {
match self {
OutputFormat::Mp4 => "ffmpeg_muxer",
OutputFormat::Mkv => "ffmpeg_muxer",
OutputFormat::Mov => "ffmpeg_muxer",
OutputFormat::Flv => "ffmpeg_muxer",
}
}
/// Get the FFmpeg format name.
pub fn ffmpeg_format(&self) -> &'static str {
match self {
OutputFormat::Mp4 => "mp4",
OutputFormat::Mkv => "matroska",
OutputFormat::Mov => "mov",
OutputFormat::Flv => "flv",
}
}
}
impl From<&str> for OutputFormat {
fn from(s: &str) -> Self {
match s.to_lowercase().as_str() {
"mp4" => OutputFormat::Mp4,
"mkv" => OutputFormat::Mkv,
"mov" => OutputFormat::Mov,
"flv" => OutputFormat::Flv,
_ => OutputFormat::Mp4,
}
}
}
/// Recording statistics.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RecordingStats {
/// Total frames recorded.
pub frames_recorded: u64,
/// Frames dropped due to encoding lag.
pub frames_dropped: u64,
/// Average frame time in milliseconds.
pub avg_frame_time_ms: f64,
/// Current bitrate in kbps.
pub current_bitrate: u32,
/// Recording duration in seconds.
pub duration_secs: f64,
}
impl RecordingStats {
/// Calculate the drop rate percentage.
pub fn drop_rate(&self) -> f64 {
if self.frames_recorded == 0 {
return 0.0;
}
(self.frames_dropped as f64 / self.frames_recorded as f64) * 100.0
}
/// Check if the recording is healthy (low drop rate).
pub fn is_healthy(&self) -> bool {
self.drop_rate() < 5.0 // Less than 5% dropped frames
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_output_format_from_str() {
assert_eq!(OutputFormat::from("mp4"), OutputFormat::Mp4);
assert_eq!(OutputFormat::from("MKV"), OutputFormat::Mkv);
assert_eq!(OutputFormat::from("unknown"), OutputFormat::Mp4);
}
#[test]
fn test_recording_result_duration_human() {
let result = RecordingResult {
path: PathBuf::from("/tmp/test.mp4"),
game_id: Some(12345),
champion: Some("Ahri".to_string()),
start_time: Utc::now(),
end_time: Utc::now() + Duration::seconds(125),
duration: Duration::seconds(125),
};
assert_eq!(result.duration_human(), "2m 5s");
}
#[test]
fn test_recording_stats_drop_rate() {
let stats = RecordingStats {
frames_recorded: 1000,
frames_dropped: 50,
..Default::default()
};
assert_eq!(stats.drop_rate(), 5.0);
assert!(stats.is_healthy());
}
}