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,29 @@
//! Configuration module for the record daemon.
//!
//! This module handles user-configurable settings, encoding presets,
//! and configuration file persistence.
mod persistence;
mod presets;
mod settings;
pub use persistence::{load_config, save_config, ConfigPersistence};
pub use presets::{AmfQuality, AudioSettings, EncoderPreset, QualityLevel};
pub use settings::{OutputSettings, Settings, VideoSettings};
use std::path::PathBuf;
/// Default configuration file name.
pub const CONFIG_FILE_NAME: &str = "config.toml";
/// Get the default configuration directory.
pub fn get_config_dir() -> Option<PathBuf> {
directories::ProjectDirs::from("com", "leaguerecorder", "record-daemon")
.map(|dirs| dirs.config_dir().to_path_buf())
}
/// Get the default output directory for recordings.
pub fn get_default_output_dir() -> Option<PathBuf> {
directories::ProjectDirs::from("com", "leaguerecorder", "record-daemon")
.map(|dirs| dirs.data_dir().join("recordings"))
}

View File

@@ -0,0 +1,176 @@
//! Configuration persistence - loading and saving settings.
use std::fs;
use std::io::{self, Read, Write};
use std::path::PathBuf;
use tracing::{debug, info, warn};
use super::{get_config_dir, Settings, CONFIG_FILE_NAME};
use crate::error::{ConfigError, Result};
/// Configuration persistence handler.
pub struct ConfigPersistence {
config_path: PathBuf,
}
impl ConfigPersistence {
/// Create a new persistence handler with the given config path.
pub fn new(config_path: PathBuf) -> Self {
Self { config_path }
}
/// Create a persistence handler using the default config location.
pub fn default_location() -> io::Result<Self> {
let config_dir = get_config_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Config directory not found"))?;
Ok(Self::new(config_dir.join(CONFIG_FILE_NAME)))
}
/// Get the configuration file path.
pub fn config_path(&self) -> &PathBuf {
&self.config_path
}
/// Check if the configuration file exists.
pub fn exists(&self) -> bool {
self.config_path.exists()
}
/// Load settings from the configuration file.
pub fn load(&self) -> Result<Settings> {
if !self.exists() {
debug!("Config file not found, using defaults");
return Ok(Settings::default());
}
let mut file = fs::File::open(&self.config_path).map_err(ConfigError::ReadError)?;
let mut contents = String::new();
file.read_to_string(&mut contents)
.map_err(ConfigError::ReadError)?;
let settings: Settings = toml::from_str(&contents).map_err(ConfigError::from)?;
info!("Loaded configuration from {:?}", self.config_path);
Ok(settings)
}
/// Save settings to the configuration file.
pub fn save(&self, settings: &Settings) -> Result<()> {
// Ensure parent directory exists
if let Some(parent) = self.config_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent).map_err(ConfigError::WriteError)?;
debug!("Created config directory: {:?}", parent);
}
}
let contents = toml::to_string_pretty(settings)
.map_err(|e| ConfigError::InvalidConfig(e.to_string()))?;
let mut file = fs::File::create(&self.config_path).map_err(ConfigError::WriteError)?;
file.write_all(contents.as_bytes())
.map_err(ConfigError::WriteError)?;
info!("Saved configuration to {:?}", self.config_path);
Ok(())
}
/// Validate settings and apply any necessary migrations.
pub fn validate_and_migrate(&self, settings: &mut Settings) -> Result<()> {
// Ensure output directory exists
if !settings.output.path.exists() {
fs::create_dir_all(&settings.output.path).map_err(|e| {
ConfigError::InvalidConfig(format!("Cannot create output directory: {}", e))
})?;
debug!("Created output directory: {:?}", settings.output.path);
}
// Validate frame rate
if settings.video.frame_rate == 0 || settings.video.frame_rate > 240 {
warn!(
"Invalid frame rate {}, defaulting to 60",
settings.video.frame_rate
);
settings.video.frame_rate = 60;
}
// Validate container format
let valid_containers = ["mp4", "mkv", "mov", "flv"];
if !valid_containers.contains(&settings.output.container.as_str()) {
warn!(
"Invalid container format '{}', defaulting to mp4",
settings.output.container
);
settings.output.container = "mp4".to_string();
}
// Validate log level
let valid_levels = ["trace", "debug", "info", "warn", "error"];
if !valid_levels.contains(&settings.daemon.log_level.as_str()) {
warn!(
"Invalid log level '{}', defaulting to info",
settings.daemon.log_level
);
settings.daemon.log_level = "info".to_string();
}
Ok(())
}
}
/// Load configuration from the default location.
pub fn load_config() -> Result<Settings> {
let persistence = ConfigPersistence::default_location().map_err(|e| {
ConfigError::InvalidConfig(format!("Cannot access config directory: {}", e))
})?;
let mut settings = persistence.load()?;
persistence.validate_and_migrate(&mut settings)?;
Ok(settings)
}
/// Save configuration to the default location.
pub fn save_config(settings: &Settings) -> Result<()> {
let persistence = ConfigPersistence::default_location().map_err(|e| {
ConfigError::InvalidConfig(format!("Cannot access config directory: {}", e))
})?;
persistence.save(settings)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_save_and_load_config() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let persistence = ConfigPersistence::new(config_path);
let settings = Settings::default();
persistence.save(&settings).unwrap();
assert!(persistence.exists());
let loaded = persistence.load().unwrap();
assert_eq!(settings.video.frame_rate, loaded.video.frame_rate);
assert_eq!(settings.daemon.auto_record, loaded.daemon.auto_record);
}
#[test]
fn test_load_nonexistent_config() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("nonexistent.toml");
let persistence = ConfigPersistence::new(config_path);
let settings = persistence.load().unwrap();
assert_eq!(settings.video.frame_rate, 60);
}
}

View File

@@ -0,0 +1,303 @@
//! Encoding presets and quality levels for video recording.
use serde::{Deserialize, Serialize};
/// Video encoder preset configuration.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum EncoderPreset {
/// NVIDIA NVENC hardware encoder.
Nvenc {
/// Target bitrate in kbps.
#[serde(default = "default_nvenc_bitrate")]
bitrate: u32,
/// Constant Quality level (lower = better quality).
#[serde(default = "default_nvenc_cq")]
cq_level: u32,
/// Use two-pass encoding.
#[serde(default = "default_two_pass")]
two_pass: bool,
},
/// AMD AMF hardware encoder.
Amf {
/// Target bitrate in kbps.
#[serde(default = "default_amf_bitrate")]
bitrate: u32,
/// Quality preset (speed, balanced, quality).
#[serde(default = "default_amf_quality")]
quality: AmfQuality,
},
/// Software x264 encoder (fallback).
X264 {
/// x264 preset (ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow).
#[serde(default = "default_x264_preset")]
preset: String,
/// Target bitrate in kbps.
#[serde(default = "default_x264_bitrate")]
bitrate: u32,
/// Use constant rate factor instead of bitrate.
#[serde(default)]
crf: Option<u32>,
},
}
impl Default for EncoderPreset {
fn default() -> Self {
Self::Nvenc {
bitrate: default_nvenc_bitrate(),
cq_level: default_nvenc_cq(),
two_pass: default_two_pass(),
}
}
}
fn default_nvenc_bitrate() -> u32 {
8000
}
fn default_nvenc_cq() -> u32 {
20
}
fn default_two_pass() -> bool {
true
}
fn default_amf_bitrate() -> u32 {
8000
}
fn default_amf_quality() -> AmfQuality {
AmfQuality::Balanced
}
fn default_x264_preset() -> String {
"veryfast".to_string()
}
fn default_x264_bitrate() -> u32 {
6000
}
/// AMD AMF quality preset.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum AmfQuality {
Speed,
#[default]
Balanced,
Quality,
}
/// Quality level presets that configure resolution and other parameters.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum QualityLevel {
/// 720p @ 30fps, lower bitrate.
Low,
/// 1080p @ 30fps, moderate bitrate.
Medium,
/// 1080p @ 60fps, high bitrate.
#[default]
High,
/// 1440p @ 60fps, maximum quality.
Ultra,
}
impl QualityLevel {
/// Get the target resolution for this quality level.
pub fn resolution(&self) -> (u32, u32) {
match self {
QualityLevel::Low => (1280, 720),
QualityLevel::Medium => (1920, 1080),
QualityLevel::High => (1920, 1080),
QualityLevel::Ultra => (2560, 1440),
}
}
/// Get the recommended frame rate for this quality level.
pub fn frame_rate(&self) -> u32 {
match self {
QualityLevel::Low => 30,
QualityLevel::Medium => 30,
QualityLevel::High => 60,
QualityLevel::Ultra => 60,
}
}
/// Get the recommended bitrate in kbps for this quality level.
pub fn recommended_bitrate(&self) -> u32 {
match self {
QualityLevel::Low => 4500,
QualityLevel::Medium => 6000,
QualityLevel::High => 8000,
QualityLevel::Ultra => 12000,
}
}
}
fn preset_default_true() -> bool {
true
}
/// Audio capture settings.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AudioSettings {
/// Enable audio recording.
#[serde(default = "preset_default_true")]
pub enabled: bool,
/// Audio bitrate in kbps.
#[serde(default = "default_audio_bitrate")]
pub bitrate: u32,
/// Sample rate in Hz.
#[serde(default = "default_sample_rate")]
pub sample_rate: u32,
/// Number of audio channels.
#[serde(default = "default_channels")]
pub channels: u32,
/// Capture game audio.
#[serde(default = "preset_default_true")]
pub capture_game: bool,
/// Capture microphone.
#[serde(default)]
pub capture_mic: bool,
/// Microphone device name (if capture_mic is true).
#[serde(default)]
pub mic_device: Option<String>,
}
impl Default for AudioSettings {
fn default() -> Self {
Self {
enabled: true,
bitrate: default_audio_bitrate(),
sample_rate: default_sample_rate(),
channels: default_channels(),
capture_game: true,
capture_mic: false,
mic_device: None,
}
}
}
fn default_audio_bitrate() -> u32 {
192
}
fn default_sample_rate() -> u32 {
48000
}
fn default_channels() -> u32 {
2
}
/// Encoder capabilities detection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EncoderCapability {
/// NVIDIA NVENC available.
Nvenc,
/// AMD AMF available.
Amf,
/// Intel QuickSync available.
QuickSync,
/// Software encoding only.
Software,
}
impl EncoderPreset {
/// Check if this preset uses hardware encoding.
pub fn is_hardware(&self) -> bool {
matches!(
self,
EncoderPreset::Nvenc { .. } | EncoderPreset::Amf { .. }
)
}
/// Get the encoder name for OBS.
pub fn encoder_name(&self) -> &'static str {
match self {
EncoderPreset::Nvenc { .. } => "jim_nvenc",
EncoderPreset::Amf { .. } => "amd_amf_h264",
EncoderPreset::X264 { .. } => "x264",
}
}
/// Get the effective bitrate for this preset.
pub fn effective_bitrate(&self) -> u32 {
match self {
EncoderPreset::Nvenc { bitrate, .. } => *bitrate,
EncoderPreset::Amf { bitrate, .. } => *bitrate,
EncoderPreset::X264 { bitrate, .. } => *bitrate,
}
}
/// Create a preset optimized for the given quality level.
pub fn for_quality(quality: QualityLevel, capability: EncoderCapability) -> Self {
let bitrate = quality.recommended_bitrate();
match capability {
EncoderCapability::Nvenc => EncoderPreset::Nvenc {
bitrate,
cq_level: 20,
two_pass: true,
},
EncoderCapability::Amf => EncoderPreset::Amf {
bitrate,
quality: AmfQuality::Balanced,
},
EncoderCapability::QuickSync => EncoderPreset::X264 {
preset: "veryfast".to_string(),
bitrate,
crf: None,
},
EncoderCapability::Software => EncoderPreset::X264 {
preset: "superfast".to_string(),
bitrate: (bitrate as f32 * 0.8) as u32,
crf: Some(23),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quality_level_resolution() {
assert_eq!(QualityLevel::Low.resolution(), (1280, 720));
assert_eq!(QualityLevel::High.resolution(), (1920, 1080));
assert_eq!(QualityLevel::Ultra.resolution(), (2560, 1440));
}
#[test]
fn test_encoder_preset_hardware_detection() {
let nvenc = EncoderPreset::Nvenc {
bitrate: 8000,
cq_level: 20,
two_pass: true,
};
assert!(nvenc.is_hardware());
let x264 = EncoderPreset::X264 {
preset: "fast".to_string(),
bitrate: 6000,
crf: None,
};
assert!(!x264.is_hardware());
}
#[test]
fn test_preset_for_quality() {
let preset = EncoderPreset::for_quality(QualityLevel::High, EncoderCapability::Nvenc);
assert_eq!(preset.effective_bitrate(), 8000);
assert!(preset.is_hardware());
}
}

View File

@@ -0,0 +1,179 @@
//! User-configurable settings for the record daemon.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use super::presets::{AudioSettings, EncoderPreset, QualityLevel};
use crate::config::get_default_output_dir;
/// Main settings structure.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Settings {
/// Video recording settings.
#[serde(default)]
pub video: VideoSettings,
/// Output settings.
#[serde(default)]
pub output: OutputSettings,
/// Audio capture settings.
#[serde(default)]
pub audio: AudioSettings,
/// Daemon behavior settings.
#[serde(default)]
pub daemon: DaemonSettings,
}
/// Video recording settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoSettings {
/// Encoder preset (codec and quality settings).
#[serde(default)]
pub encoder_preset: EncoderPreset,
/// Quality level preset.
#[serde(default)]
pub quality: QualityLevel,
/// Target frame rate.
#[serde(default = "default_frame_rate")]
pub frame_rate: u32,
/// Enable hardware acceleration.
#[serde(default = "default_true")]
pub hardware_acceleration: bool,
}
impl Default for VideoSettings {
fn default() -> Self {
Self {
encoder_preset: EncoderPreset::default(),
quality: QualityLevel::default(),
frame_rate: default_frame_rate(),
hardware_acceleration: true,
}
}
}
fn default_frame_rate() -> u32 {
60
}
fn default_true() -> bool {
true
}
/// Output settings for recordings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputSettings {
/// Directory where recordings will be saved.
#[serde(default = "default_output_path")]
pub path: PathBuf,
/// File naming pattern.
/// Supports: {date}, {time}, {game_id}, {champion}, {queue_type}
#[serde(default = "default_naming_pattern")]
pub naming_pattern: String,
/// Container format (mp4, mkv, etc.).
#[serde(default = "default_container")]
pub container: String,
/// Split recordings by size (MB). 0 = no splitting.
#[serde(default)]
pub split_size_mb: u32,
/// Split recordings by time (minutes). 0 = no splitting.
#[serde(default)]
pub split_time_minutes: u32,
}
impl Default for OutputSettings {
fn default() -> Self {
Self {
path: default_output_path(),
naming_pattern: default_naming_pattern(),
container: default_container(),
split_size_mb: 0,
split_time_minutes: 0,
}
}
}
fn default_output_path() -> PathBuf {
get_default_output_dir().unwrap_or_else(|| PathBuf::from("./recordings"))
}
fn default_naming_pattern() -> String {
"{date}_{time}_{champion}".to_string()
}
fn default_container() -> String {
"mp4".to_string()
}
/// Daemon behavior settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaemonSettings {
/// Automatically start recording when a match begins.
#[serde(default = "default_true")]
pub auto_record: bool,
/// Monitor for League Client startup.
#[serde(default = "default_true")]
pub monitor_client: bool,
/// Polling interval for client detection (milliseconds).
#[serde(default = "default_poll_interval")]
pub poll_interval_ms: u64,
/// IPC socket path. If None, uses default path.
#[serde(default)]
pub socket_path: Option<PathBuf>,
/// Log level (trace, debug, info, warn, error).
#[serde(default = "default_log_level")]
pub log_level: String,
/// Keep event timeline after recording ends.
#[serde(default = "default_true")]
pub save_timeline: bool,
}
impl Default for DaemonSettings {
fn default() -> Self {
Self {
auto_record: true,
monitor_client: true,
poll_interval_ms: default_poll_interval(),
socket_path: None,
log_level: default_log_level(),
save_timeline: true,
}
}
}
fn default_poll_interval() -> u64 {
1000
}
fn default_log_level() -> String {
"info".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_settings_serialization() {
let settings = Settings::default();
let toml_str = toml::to_string_pretty(&settings).unwrap();
let parsed: Settings = toml::from_str(&toml_str).unwrap();
assert_eq!(settings.video.frame_rate, parsed.video.frame_rate);
assert_eq!(settings.daemon.auto_record, parsed.daemon.auto_record);
}
}