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

1
record-daemon/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

2414
record-daemon/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

80
record-daemon/Cargo.toml Normal file
View File

@@ -0,0 +1,80 @@
[package]
name = "record-daemon"
version = "0.1.0"
edition = "2021"
description = "High-performance League of Legends recording daemon using libobs"
authors = ["LeagueRecorder"]
license = "MIT"
[dependencies]
# Async runtime
tokio = { version = "1", features = ["full"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
# WebSocket for LQP
tokio-tungstenite = "0.21"
# HTTP client for LQP REST API
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
# Async utilities
futures = "0.3"
async-trait = "0.1"
# Error handling
thiserror = "1"
anyhow = "1"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# UUID for recording IDs
uuid = { version = "1", features = ["v4", "serde"] }
# Date/time
chrono = { version = "0.4", features = ["serde"] }
# Locking primitives
parking_lot = "0.12"
# Crossbeam channels
crossbeam = "0.8"
# Unix socket IPC
tokio-util = { version = "0.7", features = ["codec"] }
# Configuration directories
directories = "5"
# CLI (for debugging and control)
clap = { version = "4", features = ["derive"] }
# Signal handling for graceful shutdown
signal-hook = "0.3"
signal-hook-tokio = { version = "0.3", features = ["futures-v0_3"] }
# Base64 for LQP authentication
base64 = "0.22"
# Regex for lockfile parsing
regex = "1"
[dev-dependencies]
# Testing utilities
tokio-test = "0.4"
tempfile = "3"
[[bin]]
name = "record-daemon"
path = "src/main.rs"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true

627
record-daemon/README.md Normal file
View File

@@ -0,0 +1,627 @@
# Record Daemon
A high-performance League of Legends recording daemon built with Rust and libobs.
## Features
- **Automatic Recording**: Detects when League of Legends starts and automatically records matches
- **Game Event Capture**: Captures game events (kills, deaths, objectives) via the League Client API (LQP)
- **Event Timeline**: Maps game events to video timestamps for easy navigation
- **Hardware Encoding**: Supports NVIDIA NVENC, AMD AMF, and software encoding (x264)
- **IPC Interface**: Unix sockets (Linux) / Named pipes (Windows) for configuration and control from a Tauri app
- **Configurable Presets**: Quality presets from 720p30 to 1440p60
- **Cross-Platform**: Supports Windows and Linux
## Architecture
```
record-daemon/
├── src/
│ ├── main.rs # Entry point, daemon setup
│ ├── lib.rs # Library exports
│ ├── config/ # Configuration management
│ │ ├── mod.rs
│ │ ├── settings.rs # User settings
│ │ ├── presets.rs # Encoder presets
│ │ └── persistence.rs # Config file I/O
│ ├── lqp/ # League Client API
│ │ ├── mod.rs
│ │ ├── auth.rs # Lockfile authentication
│ │ ├── client.rs # WebSocket/REST client
│ │ └── events.rs # Game event types
│ ├── recording/ # libobs recording
│ │ ├── mod.rs
│ │ ├── obs_context.rs # OBS initialization
│ │ ├── encoder.rs # Video encoding
│ │ ├── capture.rs # Game capture
│ │ └── output.rs # File output
│ ├── ipc/ # IPC server
│ │ ├── mod.rs
│ │ ├── server.rs # Platform-specific server
│ │ ├── protocol.rs # Message protocol
│ │ └── handlers.rs # Command handlers
│ ├── timeline/ # Event timeline
│ │ ├── mod.rs
│ │ ├── store.rs # Storage backend
│ │ └── mapper.rs # Event-to-video mapping
│ ├── state/ # State machine
│ │ ├── mod.rs
│ │ └── machine.rs # State transitions
│ └── error.rs # Error types
```
## Platform Support
| Feature | Linux | Windows |
|---------|-------|---------|
| Game Detection | ✅ Wine/Proton paths | ✅ Native paths |
| IPC Transport | Unix sockets | Named pipes |
| Lockfile Detection | ✅ | ✅ |
| Hardware Encoding | NVENC | NVENC, AMF |
## Building
### Linux
```bash
cd record-daemon
cargo build --release
```
### Windows
```powershell
cd record-daemon
cargo build --release
```
**Prerequisites for Windows:**
- Visual Studio Build Tools (MSVC)
- OBS Studio installed (for libobs DLLs)
## Usage
### Running the Daemon
```bash
# Run with default settings
./record-daemon
# Run with custom config
./record-daemon --config /path/to/config.toml
# Run with debug logging
./record-daemon --log-level debug
# Run in foreground
./record-daemon --foreground
```
### Command Line Options
| Option | Description |
|--------|-------------|
| `-c, --config <PATH>` | Path to configuration file |
| `-l, --log-level <LEVEL>` | Log level (trace, debug, info, warn, error) |
| `-f, --foreground` | Run in foreground (don't daemonize) |
| `-s, --socket <PATH>` | Socket path for IPC |
## Configuration
The configuration file is stored at:
- Linux: `~/.config/record-daemon/config.toml`
- Windows: `%APPDATA%\record-daemon\config.toml`
### Example Configuration
```toml
[video]
encoder_preset = { type = "nvenc", bitrate = 8000, cq_level = 20, two_pass = true }
quality = "high"
frame_rate = 60
hardware_acceleration = true
[output]
path = "C:\\Users\\User\\Videos\\LeagueRecordings" # Windows
# path = "/home/user/Videos/LeagueRecordings" # Linux
naming_pattern = "{date}_{time}_{champion}"
container = "mp4"
[audio]
enabled = true
bitrate = 192
sample_rate = 48000
channels = 2
capture_game = true
capture_mic = false
[daemon]
auto_record = true
monitor_client = true
poll_interval_ms = 1000
log_level = "info"
save_timeline = true
```
### Encoder Presets
#### NVIDIA NVENC
```toml
[video.encoder_preset]
type = "nvenc"
bitrate = 8000
cq_level = 20
two_pass = true
```
#### AMD AMF (Windows only)
```toml
[video.encoder_preset]
type = "amf"
bitrate = 8000
quality = "balanced"
```
#### x264 (Software)
```toml
[video.encoder_preset]
type = "x264"
preset = "veryfast"
bitrate = 6000
crf = 23
```
### Quality Levels
| Level | Resolution | FPS | Bitrate |
|-------|------------|-----|---------|
| Low | 720p | 30 | 4500 kbps |
| Medium | 1080p | 30 | 6000 kbps |
| High | 1080p | 60 | 8000 kbps |
| Ultra | 1440p | 60 | 12000 kbps |
## IPC Protocol
The daemon exposes an IPC server for communication with the Tauri app.
### Socket Location
- Linux: `$XDG_RUNTIME_DIR/record-daemon.sock` (or `/tmp/record-daemon.sock`)
- Windows: `\\.\pipe\record-daemon` (named pipe)
### Message Format
All messages are JSON-encoded:
```json
{
"type": "request",
"id": "uuid-v4",
"command": "GetSettings",
"payload": null
}
```
### Available Commands
| Command | Description |
|---------|-------------|
| `GetSettings` | Get current settings |
| `UpdateSettings` | Update settings |
| `ResetSettings` | Reset to defaults |
| `GetStatus` | Get daemon status |
| `GetEncoders` | Get available encoders |
| `StartRecording` | Start recording manually |
| `StopRecording` | Stop recording |
| `GetRecordings` | List all recordings |
| `GetTimeline` | Get event timeline for a recording |
| `Shutdown` | Shutdown the daemon |
### Example IPC Session (Linux)
```bash
# Connect to socket
nc -U /tmp/record-daemon.sock
# Get status
{"type":"request","id":"00000000-0000-0000-0000-000000000001","command":"GetStatus"}
# Response
{"request_id":"00000000-0000-0000-0000-000000000001","success":true,"data":{"status":"idle","isRecording":false,"clientConnected":false}}
```
### Example IPC Session (Windows)
```powershell
# Connect to named pipe using PowerShell
$client = New-Object System.IO.Pipes.NamedPipeClientStream(".", "record-daemon", [System.IO.Pipes.PipeDirection]::InOut)
$client.Connect()
$reader = New-Object System.IO.StreamReader($client)
$writer = New-Object System.IO.StreamWriter($client)
# Send command
$writer.WriteLine('{"type":"request","id":"00000000-0000-0000-0000-000000000001","command":"GetStatus"}')
$writer.Flush()
# Read response
$response = $reader.ReadLine()
Write-Host $response
$client.Close()
```
## Event Timeline
Each recording has an associated timeline that maps game events to video timestamps:
```json
{
"recording_id": "uuid",
"start_time": "2024-01-15T10:30:00Z",
"end_time": "2024-01-15T11:15:00Z",
"duration_secs": 2700,
"events": [
{
"video_timestamp": 120,
"game_timestamp": 115,
"event_type": "kill",
"description": "Player1 killed Player2",
"timestamp": "2024-01-15T10:32:00Z"
}
]
}
```
---
## Windows Test Plan
This section provides a comprehensive test plan for validating the daemon on Windows with League of Legends.
### Prerequisites
1. **System Requirements**
- Windows 10/11 64-bit
- League of Legends installed
- OBS Studio installed (for libobs)
- NVIDIA GPU (for NVENC) or AMD GPU (for AMF) - optional but recommended
2. **Build Requirements**
- Rust toolchain (stable)
- Visual Studio Build Tools 2022
- Windows SDK
### Test Environment Setup
```powershell
# 1. Install Rust if not already installed
winget install Rustlang.Rustup
# 2. Verify Rust installation
rustc --version
cargo --version
# 3. Build the daemon
cd record-daemon
cargo build --release
```
### Test 1: Daemon Startup
**Objective:** Verify daemon starts correctly on Windows.
**Steps:**
```powershell
# Run daemon in foreground with debug logging
.\target\release\record-daemon.exe --foreground --log-level debug
```
**Expected Results:**
- Daemon starts without errors
- Named pipe `\\.\pipe\record-daemon` is created
- Log shows "IPC server started successfully"
- Config file created at `%APPDATA%\record-daemon\config.toml`
**Verification:**
```powershell
# Check if named pipe exists
[System.IO.Directory]::GetFiles("\\.\\pipe\\") | Where-Object { $_ -like "*record-daemon*" }
```
### Test 2: League Client Detection
**Objective:** Verify daemon detects League Client startup.
**Steps:**
1. Start the daemon with debug logging
2. Launch League of Legends Client
3. Wait for client to fully load (login screen)
**Expected Results:**
- Log shows "League Client detected (PID: XXXX, Port: XXXX)"
- Lockfile found at `C:\Riot Games\League of Legends\lockfile`
- Daemon transitions to Monitoring state
**Verification:**
```powershell
# Check lockfile exists
Get-Content "C:\Riot Games\League of Legends\lockfile"
# Expected format: LeagueClient:PID:PORT:PASSWORD:PROTOCOL
# Example: LeagueClient:12345:52432:abc123:https
```
### Test 3: Game Start Detection
**Objective:** Verify daemon detects game start and begins recording.
**Steps:**
1. Start daemon
2. Launch League Client
3. Start a game (Practice Tool, ARAM, or Summoner's Rift)
4. Wait for game to load
**Expected Results:**
- Log shows "Starting recording for game XXXXX"
- Recording file created in output directory
- Daemon transitions to Recording state
**Verification:**
```powershell
# Check for recording files
Get-ChildItem -Path "C:\Users\$env:USERNAME\Videos\LeagueRecordings" -Recurse
```
### Test 4: Event Capture
**Objective:** Verify game events are captured and mapped to timeline.
**Steps:**
1. Start daemon
2. Play a game (Practice Tool recommended for controlled testing)
3. Perform actions: kills, deaths, take objectives
4. End the game
**Expected Results:**
- Events logged in debug output
- Timeline JSON file created after game ends
- Events contain correct timestamps
**Verification:**
```powershell
# Check timeline files
Get-ChildItem -Path "$env:APPDATA\record-daemon\timelines" -Recurse
# View timeline content
Get-Content "$env:APPDATA\record-daemon\timelines\*.json" | ConvertFrom-Json
```
### Test 5: Game End Detection
**Objective:** Verify daemon stops recording when game ends.
**Steps:**
1. Start daemon
2. Play a game
3. End the game (win, lose, or surrender)
**Expected Results:**
- Log shows "Stopping recording"
- Recording file finalized
- Timeline saved
- Daemon transitions back to Monitoring state
### Test 6: IPC Communication
**Objective:** Verify IPC commands work on Windows named pipes.
**Steps:**
```powershell
# Create test script
$pipe = New-Object System.IO.Pipes.NamedPipeClientStream(".", "record-daemon", [System.IO.Pipes.PipeDirection]::InOut)
$pipe.Connect(5000)
$reader = New-Object System.IO.StreamReader($pipe)
$writer = New-Object System.IO.StreamWriter($pipe)
# Test GetStatus
$writer.WriteLine('{"type":"request","id":"00000000-0000-0000-0000-000000000001","command":"GetStatus"}')
$writer.Flush()
$response = $reader.ReadLine()
Write-Host "Status: $response"
# Test GetEncoders
$writer.WriteLine('{"type":"request","id":"00000000-0000-0000-0000-000000000002","command":"GetEncoders"}')
$writer.Flush()
$response = $reader.ReadLine()
Write-Host "Encoders: $response"
$pipe.Close()
```
**Expected Results:**
- GetStatus returns current daemon state
- GetEncoders returns available hardware encoders
### Test 7: Client Disconnect/Reconnect
**Objective:** Verify daemon handles client restarts correctly.
**Steps:**
1. Start daemon
2. Launch League Client
3. Close League Client
4. Reopen League Client
**Expected Results:**
- Log shows "League Client stopped" when closed
- Log shows "League Client detected" when reopened
- Daemon state transitions correctly
### Test 8: Multiple Game Sessions
**Objective:** Verify daemon handles multiple consecutive games.
**Steps:**
1. Start daemon
2. Play 3 consecutive games
3. Check all recordings
**Expected Results:**
- Each game creates separate recording
- Each recording has correct timeline
- No memory leaks or performance degradation
### Test 9: Hardware Encoding
**Objective:** Verify hardware encoding works correctly.
**Steps:**
1. Configure NVENC or AMF in config
2. Start daemon
3. Play a game
4. Check recording quality
**NVIDIA GPU Verification:**
```powershell
# Check NVENC is being used
nvidia-smi dmon -s u -d pwr,enc
# Should show encoder utilization during recording
```
**Expected Results:**
- Recording uses hardware encoder
- GPU encoder utilization shown in monitoring tools
- CPU usage remains low during recording
### Test 10: Error Recovery
**Objective:** Verify daemon recovers from errors gracefully.
**Steps:**
1. Start daemon
2. Start recording
3. Simulate error scenarios:
- Kill game process
- Delete output directory
- Fill disk space (careful!)
**Expected Results:**
- Daemon logs error appropriately
- Daemon recovers and continues operation
- No crash or hang
### Test 11: Configuration Persistence
**Objective:** Verify settings are saved and loaded correctly.
**Steps:**
```powershell
# Edit config
$configPath = "$env:APPDATA\record-daemon\config.toml"
notepad $configPath
# Restart daemon
.\target\release\record-daemon.exe --foreground
```
**Expected Results:**
- Config changes persist across restarts
- Invalid config shows appropriate error
- Default config created if missing
### Test 12: Shutdown Handling
**Objective:** Verify clean shutdown.
**Steps:**
1. Start daemon
2. Start recording
3. Send Ctrl+C or shutdown signal
**Expected Results:**
- Recording stops cleanly
- Timeline saved
- Named pipe cleaned up
- No orphaned processes
---
## Development
### Running Tests
```bash
cargo test
```
### Building Documentation
```bash
cargo doc --open
```
### Code Structure
The daemon follows a state machine pattern:
```
Idle -> Monitoring -> Recording -> Monitoring -> Idle
| | |
v v v
Error <-------- Error -------- Error
|
v
Idle (recovery)
```
## Dependencies
- **tokio**: Async runtime
- **tokio-tungstenite**: WebSocket for LQP
- **reqwest**: HTTP client for LQP REST API
- **serde/serde_json**: Serialization
- **tracing**: Logging
- **parking_lot**: High-performance locks
- **chrono**: Date/time handling
- **uuid**: Unique identifiers
## Troubleshooting
### Windows: Named Pipe Connection Failed
```
Error: Cannot connect to named pipe
```
**Solution:** Ensure daemon is running and pipe exists:
```powershell
# Check pipe exists
[System.IO.Directory]::GetFiles("\\.\\pipe\\") | Where-Object { $_ -like "*record-daemon*" }
```
### Windows: Lockfile Not Found
```
Error: League Client lockfile not found
```
**Solution:** Check League Client installation path:
```powershell
# Common paths
Test-Path "C:\Riot Games\League of Legends\lockfile"
Test-Path "D:\Riot Games\League of Legends\lockfile"
```
### Windows: NVENC Not Available
```
Error: NVENC encoder not available
```
**Solution:**
1. Ensure NVIDIA GPU is present
2. Update NVIDIA drivers
3. Check NVENC support: `nvidia-smi`
## License
MIT

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

171
record-daemon/src/error.rs Normal file
View File

@@ -0,0 +1,171 @@
//! Error types for the record daemon.
use thiserror::Error;
/// Main error type for the daemon.
#[derive(Debug, Error)]
pub enum DaemonError {
#[error("Configuration error: {0}")]
Config(#[from] ConfigError),
#[error("LQP connection error: {0}")]
Lqp(#[from] LqpError),
#[error("Recording error: {0}")]
Recording(#[from] RecordingError),
#[error("IPC error: {0}")]
Ipc(#[from] IpcError),
#[error("Timeline error: {0}")]
Timeline(#[from] TimelineError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("WebSocket error: {0}")]
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
#[error("HTTP request error: {0}")]
Http(#[from] reqwest::Error),
}
/// Configuration-related errors.
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Failed to read configuration file: {0}")]
ReadError(#[source] std::io::Error),
#[error("Failed to write configuration file: {0}")]
WriteError(#[source] std::io::Error),
#[error("Invalid configuration: {0}")]
InvalidConfig(String),
#[error("Failed to parse configuration: {0}")]
ParseError(String),
#[error("Configuration directory not found")]
ConfigDirNotFound,
#[error("Invalid encoder preset: {0}")]
InvalidPreset(String),
}
impl From<toml::de::Error> for ConfigError {
fn from(e: toml::de::Error) -> Self {
ConfigError::ParseError(e.to_string())
}
}
/// LQP (League Client API) related errors.
#[derive(Debug, Error)]
pub enum LqpError {
#[error("League Client lockfile not found")]
LockfileNotFound,
#[error("Failed to parse lockfile: {0}")]
LockfileParseError(String),
#[error("Failed to connect to League Client: {0}")]
ConnectionFailed(String),
#[error("WebSocket connection error: {0}")]
WebSocketError(String),
#[error("Authentication failed: {0}")]
AuthFailed(String),
#[error("Event parsing error: {0}")]
EventParseError(String),
#[error("League Client not running")]
ClientNotRunning,
#[error("Request timeout")]
Timeout,
}
/// Recording-related errors.
#[derive(Debug, Error)]
pub enum RecordingError {
#[error("Failed to initialize OBS context: {0}")]
ObsInitError(String),
#[error("Failed to create capture source: {0}")]
CaptureError(String),
#[error("Failed to configure encoder: {0}")]
EncoderError(String),
#[error("Failed to start recording: {0}")]
StartError(String),
#[error("Failed to stop recording: {0}")]
StopError(String),
#[error("Recording already in progress")]
AlreadyRecording,
#[error("No recording in progress")]
NotRecording,
#[error("Output directory not accessible: {0}")]
OutputDirError(String),
#[error("Encoder not available: {0}")]
EncoderNotAvailable(String),
#[error("Game window not found")]
GameWindowNotFound,
}
/// IPC-related errors.
#[derive(Debug, Error)]
pub enum IpcError {
#[error("Failed to bind to socket: {0}")]
BindError(#[source] std::io::Error),
#[error("Failed to accept connection: {0}")]
AcceptError(#[source] std::io::Error),
#[error("Failed to read from socket: {0}")]
ReadError(#[source] std::io::Error),
#[error("Failed to write to socket: {0}")]
WriteError(#[source] std::io::Error),
#[error("Codec error: {0}")]
CodecError(String),
#[error("Invalid command: {0}")]
InvalidCommand(String),
#[error("Protocol error: {0}")]
ProtocolError(String),
#[error("Client disconnected")]
ClientDisconnected,
}
/// Timeline-related errors.
#[derive(Debug, Error)]
pub enum TimelineError {
#[error("Failed to store event: {0}")]
StoreError(String),
#[error("Failed to load timeline: {0}")]
LoadError(String),
#[error("Recording not found: {0}")]
RecordingNotFound(uuid::Uuid),
#[error("Invalid timestamp: {0}")]
InvalidTimestamp(String),
}
/// Result type alias for daemon operations.
pub type Result<T> = std::result::Result<T, DaemonError>;

View File

@@ -0,0 +1,305 @@
//! IPC command handlers.
use std::sync::Arc;
use async_trait::async_trait;
use parking_lot::RwLock;
use tracing::{debug, info};
use super::protocol::{IpcCommand, IpcMessage, IpcResponse};
use crate::config::Settings;
use crate::error::Result;
use crate::recording::encoder::{detect_hardware_encoders, EncoderCapability};
use crate::recording::RecordingEngine;
use crate::state::DaemonStatus;
use crate::timeline::TimelineStore;
/// Command handler trait.
#[async_trait]
pub trait CommandHandler: Send + Sync {
/// Handle the command and return a response.
async fn handle(&self, payload: Option<serde_json::Value>) -> Result<serde_json::Value>;
}
/// Collection of IPC command handlers.
pub struct IpcHandlers {
/// Settings reference.
settings: Arc<RwLock<Settings>>,
/// Recording engine reference.
recording: Arc<RwLock<Option<RecordingEngine>>>,
/// Timeline store reference.
timeline: Arc<RwLock<TimelineStore>>,
/// Daemon status.
status: Arc<RwLock<DaemonStatus>>,
/// Client connection status.
client_connected: Arc<RwLock<bool>>,
}
impl IpcHandlers {
/// Create a new handlers collection.
pub fn new(
settings: Arc<RwLock<Settings>>,
recording: Arc<RwLock<Option<RecordingEngine>>>,
timeline: Arc<RwLock<TimelineStore>>,
status: Arc<RwLock<DaemonStatus>>,
client_connected: Arc<RwLock<bool>>,
) -> Self {
Self {
settings,
recording,
timeline,
status,
client_connected,
}
}
/// Handle an incoming message.
pub async fn handle(&self, message: IpcMessage) -> IpcResponse {
let command = match message.command {
Some(cmd) => cmd,
None => return IpcResponse::error(message.id, "No command specified"),
};
debug!("Handling command: {:?}", command);
let result = match command {
IpcCommand::GetSettings => self.handle_get_settings().await,
IpcCommand::UpdateSettings => self.handle_update_settings(message.payload).await,
IpcCommand::ResetSettings => self.handle_reset_settings().await,
IpcCommand::GetStatus => self.handle_get_status().await,
IpcCommand::GetEncoders => self.handle_get_encoders().await,
IpcCommand::StartRecording => self.handle_start_recording().await,
IpcCommand::StopRecording => self.handle_stop_recording().await,
IpcCommand::GetRecordings => self.handle_get_recordings().await,
IpcCommand::GetRecording { id } => self.handle_get_recording(id).await,
IpcCommand::DeleteRecording { id } => self.handle_delete_recording(id).await,
IpcCommand::GetTimeline { recording_id } => {
self.handle_get_timeline(recording_id).await
}
IpcCommand::ExportTimeline {
recording_id,
format,
} => self.handle_export_timeline(recording_id, format).await,
IpcCommand::Shutdown => self.handle_shutdown().await,
};
match result {
Ok(data) => IpcResponse::success(message.id, data),
Err(e) => {
info!("Command failed: {}", e);
IpcResponse::error(message.id, e.to_string())
}
}
}
async fn handle_get_settings(&self) -> Result<serde_json::Value> {
let settings = self.settings.read().clone();
Ok(serde_json::to_value(settings)?)
}
async fn handle_update_settings(
&self,
payload: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let payload = payload.ok_or_else(|| {
crate::error::IpcError::InvalidCommand("Missing settings payload".to_string())
})?;
let new_settings: Settings = serde_json::from_value(payload)?;
// Validate settings
if new_settings.video.frame_rate == 0 || new_settings.video.frame_rate > 240 {
return Err(
crate::error::ConfigError::InvalidConfig("Invalid frame rate".to_string()).into(),
);
}
// Update settings
*self.settings.write() = new_settings.clone();
// Persist settings
crate::config::save_config(&new_settings)?;
info!("Settings updated");
Ok(serde_json::json!({ "success": true }))
}
async fn handle_reset_settings(&self) -> Result<serde_json::Value> {
let default_settings = Settings::default();
*self.settings.write() = default_settings.clone();
crate::config::save_config(&default_settings)?;
info!("Settings reset to defaults");
Ok(serde_json::to_value(default_settings)?)
}
async fn handle_get_status(&self) -> Result<serde_json::Value> {
let status = *self.status.read();
let recording = self.recording.read();
let is_recording = recording
.as_ref()
.map(|r| r.is_recording())
.unwrap_or(false);
let client_connected = *self.client_connected.read();
Ok(serde_json::json!({
"status": status,
"isRecording": is_recording,
"clientConnected": client_connected,
}))
}
async fn handle_get_encoders(&self) -> Result<serde_json::Value> {
let capabilities = detect_hardware_encoders();
let current = self
.settings
.read()
.video
.encoder_preset
.encoder_name()
.to_string();
let encoders: Vec<_> = 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,
}),
})
.collect();
Ok(serde_json::json!({
"available": encoders,
"current": current,
}))
}
async fn handle_start_recording(&self) -> Result<serde_json::Value> {
let mut recording_guard = self.recording.write();
if let Some(ref mut engine) = *recording_guard {
if engine.is_recording() {
return Err(crate::error::RecordingError::AlreadyRecording.into());
}
engine.start_recording(None, None)?;
Ok(serde_json::json!({ "success": true }))
} else {
Err(crate::error::RecordingError::ObsInitError(
"Recording engine not initialized".to_string(),
)
.into())
}
}
async fn handle_stop_recording(&self) -> Result<serde_json::Value> {
let mut recording_guard = self.recording.write();
if let Some(ref mut engine) = *recording_guard {
let result = engine.stop_recording()?;
// Save to timeline
self.timeline.write().add_recording(result.clone())?;
Ok(serde_json::to_value(result)?)
} else {
Err(crate::error::RecordingError::NotRecording.into())
}
}
async fn handle_get_recordings(&self) -> Result<serde_json::Value> {
let recordings = self.timeline.read().get_all_recordings()?;
Ok(serde_json::to_value(recordings)?)
}
async fn handle_get_recording(&self, id: uuid::Uuid) -> Result<serde_json::Value> {
let recording = self.timeline.read().get_recording(id)?;
Ok(serde_json::to_value(recording)?)
}
async fn handle_delete_recording(&self, id: uuid::Uuid) -> Result<serde_json::Value> {
self.timeline.write().delete_recording(id)?;
Ok(serde_json::json!({ "success": true }))
}
async fn handle_get_timeline(&self, recording_id: uuid::Uuid) -> Result<serde_json::Value> {
let timeline = self.timeline.read().get_timeline(recording_id)?;
Ok(serde_json::to_value(timeline)?)
}
async fn handle_export_timeline(
&self,
recording_id: uuid::Uuid,
format: super::protocol::ExportFormat,
) -> Result<serde_json::Value> {
let timeline = self.timeline.read().get_timeline(recording_id)?;
let exported = match format {
super::protocol::ExportFormat::Json => serde_json::to_string_pretty(&timeline)?,
super::protocol::ExportFormat::Csv => {
// Convert to CSV format
let mut csv = String::from("timestamp,event_type,description\n");
for event in &timeline.events {
csv.push_str(&format!(
"{},{},{}\n",
event.timestamp.to_rfc3339(),
event.event_type,
event.description,
));
}
csv
}
};
Ok(serde_json::json!({
"data": exported,
"format": format,
}))
}
async fn handle_shutdown(&self) -> Result<serde_json::Value> {
info!("Shutdown requested via IPC");
// Signal shutdown through status
*self.status.write() = DaemonStatus::ShuttingDown;
Ok(serde_json::json!({ "success": true }))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_handlers_creation() {
let settings = Arc::new(RwLock::new(Settings::default()));
let recording = Arc::new(RwLock::new(None));
let timeline = Arc::new(RwLock::new(TimelineStore::new()));
let status = Arc::new(RwLock::new(DaemonStatus::Idle));
let client_connected = Arc::new(RwLock::new(false));
let _handlers = IpcHandlers::new(settings, recording, timeline, status, client_connected);
}
}

View File

@@ -0,0 +1,29 @@
//! IPC module for communication with the Tauri app.
//!
//! This module provides a Unix socket-based IPC server that allows
//! the Tauri app to configure and control the daemon.
mod handlers;
mod protocol;
mod server;
pub use handlers::{CommandHandler, IpcHandlers};
pub use protocol::{IpcCommand, IpcMessage, IpcNotification, IpcResponse};
pub use server::{IpcServer, IpcServerConfig};
use std::path::PathBuf;
/// Default socket path for the IPC server.
#[cfg(target_os = "linux")]
pub fn default_socket_path() -> PathBuf {
std::env::var("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/tmp"))
.join("record-daemon.sock")
}
/// Default socket path for the IPC server.
#[cfg(target_os = "windows")]
pub fn default_socket_path() -> PathBuf {
PathBuf::from(r"\\.\pipe\record-daemon")
}

View File

@@ -0,0 +1,277 @@
//! IPC protocol definitions.
//!
//! Defines the message format and commands for IPC communication.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::config::Settings;
use crate::lqp::GameEvent;
use crate::recording::RecordingResult;
use crate::state::DaemonStatus;
use crate::timeline::RecordingMetadata;
/// IPC message wrapper.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IpcMessage {
/// Message type.
#[serde(rename = "type")]
pub message_type: MessageType,
/// Unique message ID.
pub id: Uuid,
/// Command name (for requests).
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<IpcCommand>,
/// Payload data.
#[serde(skip_serializing_if = "Option::is_none")]
pub payload: Option<serde_json::Value>,
}
/// Message type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MessageType {
/// Request message (expects response).
Request,
/// Response message.
Response,
/// Notification message (no response expected).
Notification,
}
/// IPC commands that can be sent to the daemon.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum IpcCommand {
// Configuration commands
/// Get current settings.
GetSettings,
/// Update settings.
UpdateSettings,
/// Reset settings to defaults.
ResetSettings,
// Status commands
/// Get daemon status.
GetStatus,
/// Get available encoders.
GetEncoders,
// Recording commands
/// Start recording manually.
StartRecording,
/// Stop recording.
StopRecording,
/// Get list of recordings.
GetRecordings,
/// Get specific recording details.
GetRecording { id: Uuid },
/// Delete a recording.
DeleteRecording { id: Uuid },
// Timeline commands
/// Get timeline for a recording.
GetTimeline { recording_id: Uuid },
/// Export timeline to file.
ExportTimeline {
recording_id: Uuid,
format: ExportFormat,
},
// Daemon control
/// Shutdown the daemon.
Shutdown,
}
/// Export format for timeline.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ExportFormat {
/// JSON format.
Json,
/// CSV format.
Csv,
}
/// IPC response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IpcResponse {
/// Original request ID.
pub request_id: Uuid,
/// Whether the request was successful.
pub success: bool,
/// Response data (if successful).
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
/// Error message (if failed).
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl IpcResponse {
/// Create a successful response.
pub fn success(request_id: Uuid, data: impl Serialize) -> Self {
Self {
request_id,
success: true,
data: Some(serde_json::to_value(data).unwrap_or(serde_json::Value::Null)),
error: None,
}
}
/// Create an error response.
pub fn error(request_id: Uuid, error: impl Into<String>) -> Self {
Self {
request_id,
success: false,
data: None,
error: Some(error.into()),
}
}
/// Serialize response to JSON string.
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
}
/// IPC notification (server-initiated message).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "notification", rename_all = "camelCase")]
pub enum IpcNotification {
/// Recording started.
RecordingStarted {
recording_id: Uuid,
game_id: Option<u64>,
champion: Option<String>,
},
/// Recording stopped.
RecordingStopped {
recording_id: Uuid,
result: RecordingResult,
},
/// Game event received.
GameEvent { event: GameEvent },
/// Daemon status changed.
StatusChanged { status: DaemonStatus },
/// League Client connection changed.
ClientConnectionChanged { connected: bool },
/// Settings updated.
SettingsUpdated { settings: Settings },
/// Error occurred.
Error { message: String },
}
/// Response data types for specific commands.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetSettingsResponse {
pub settings: Settings,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetStatusResponse {
pub status: DaemonStatus,
pub is_recording: bool,
pub current_game_id: Option<u64>,
pub client_connected: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetRecordingsResponse {
pub recordings: Vec<RecordingMetadata>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetEncodersResponse {
pub available: Vec<EncoderInfo>,
pub current: String,
}
/// Information about an available encoder.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EncoderInfo {
/// Encoder ID.
pub id: String,
/// Display name.
pub name: String,
/// Whether this is a hardware encoder.
pub is_hardware: bool,
/// Whether this encoder is available on this system.
pub available: bool,
}
impl IpcMessage {
/// Create a new request message.
pub fn request(command: IpcCommand, payload: Option<serde_json::Value>) -> Self {
Self {
message_type: MessageType::Request,
id: Uuid::new_v4(),
command: Some(command),
payload,
}
}
/// Create a new notification message.
pub fn notification(notification: IpcNotification) -> Self {
Self {
message_type: MessageType::Notification,
id: Uuid::new_v4(),
command: None,
payload: Some(serde_json::to_value(notification).unwrap_or(serde_json::Value::Null)),
}
}
/// Parse from JSON string.
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
/// Serialize to JSON string.
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ipc_message_creation() {
let msg = IpcMessage::request(IpcCommand::GetSettings, None);
assert_eq!(msg.message_type, MessageType::Request);
assert_eq!(msg.command, Some(IpcCommand::GetSettings));
}
#[test]
fn test_ipc_response_success() {
let id = Uuid::new_v4();
let response = IpcResponse::success(id, "test data");
assert!(response.success);
assert!(response.data.is_some());
assert!(response.error.is_none());
}
#[test]
fn test_ipc_response_error() {
let id = Uuid::new_v4();
let response = IpcResponse::error(id, "Something went wrong");
assert!(!response.success);
assert!(response.data.is_none());
assert!(response.error.is_some());
}
#[test]
fn test_message_serialization() {
let msg = IpcMessage::request(IpcCommand::GetStatus, None);
let json = msg.to_json().unwrap();
let parsed = IpcMessage::from_json(&json).unwrap();
assert_eq!(msg.command, parsed.command);
}
}

View File

@@ -0,0 +1,464 @@
//! IPC server for communication with the Tauri app.
//!
//! Uses Unix domain sockets on Linux and named pipes on Windows.
use std::path::PathBuf;
use std::sync::Arc;
use futures::SinkExt;
use futures::StreamExt;
use tokio::sync::{broadcast, RwLock};
use tokio_util::codec::{Framed, LinesCodec};
use tracing::{debug, error, info, trace, warn};
use super::handlers::IpcHandlers;
use super::protocol::{IpcMessage, IpcNotification, IpcResponse, MessageType};
use crate::error::{IpcError, Result};
/// IPC server configuration.
#[derive(Debug, Clone)]
pub struct IpcServerConfig {
/// Socket path (Linux) or pipe name (Windows).
pub socket_path: PathBuf,
/// Maximum connections.
pub max_connections: usize,
/// Connection timeout in seconds.
pub timeout_secs: u64,
}
impl Default for IpcServerConfig {
fn default() -> Self {
Self {
socket_path: super::default_socket_path(),
max_connections: 10,
timeout_secs: 30,
}
}
}
/// Platform-specific listener type.
#[cfg(target_os = "linux")]
type PlatformListener = tokio::net::UnixListener;
/// Platform-specific stream type.
#[cfg(target_os = "linux")]
type PlatformStream = tokio::net::UnixStream;
/// IPC server for communication with Tauri app.
pub struct IpcServer {
/// Server configuration.
config: IpcServerConfig,
/// Platform-specific listener.
listener: Option<PlatformListener>,
/// Command handlers.
handlers: Arc<IpcHandlers>,
/// Notification broadcaster.
notification_tx: broadcast::Sender<IpcNotification>,
/// Shutdown signal.
shutdown: Arc<RwLock<bool>>,
/// Connected clients count.
client_count: Arc<RwLock<usize>>,
}
impl IpcServer {
/// Create a new IPC server.
pub fn new(config: IpcServerConfig, handlers: IpcHandlers) -> Self {
let (notification_tx, _) = broadcast::channel(64);
Self {
config,
listener: None,
handlers: Arc::new(handlers),
notification_tx,
shutdown: Arc::new(RwLock::new(false)),
client_count: Arc::new(RwLock::new(0)),
}
}
/// Get the socket path.
pub fn socket_path(&self) -> &PathBuf {
&self.config.socket_path
}
/// Get a subscriber for notifications.
pub fn subscribe(&self) -> broadcast::Receiver<IpcNotification> {
self.notification_tx.subscribe()
}
/// Broadcast a notification to all connected clients.
pub fn broadcast(&self, notification: IpcNotification) {
if self.notification_tx.send(notification).is_err() {
trace!("No clients connected to receive notification");
}
}
/// Start the IPC server.
#[cfg(target_os = "linux")]
pub async fn start(&mut self) -> Result<()> {
// Remove existing socket if present
if self.config.socket_path.exists() {
std::fs::remove_file(&self.config.socket_path).map_err(IpcError::BindError)?;
}
// Ensure parent directory exists
if let Some(parent) = self.config.socket_path.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent).map_err(IpcError::BindError)?;
}
}
info!("Starting IPC server at {:?}", self.config.socket_path);
let listener = PlatformListener::bind(&self.config.socket_path).map_err(IpcError::BindError)?;
self.listener = Some(listener);
info!("IPC server started successfully");
Ok(())
}
/// Start the IPC server (Windows).
#[cfg(target_os = "windows")]
pub async fn start(&mut self) -> Result<()> {
// On Windows, we don't need to bind in advance for named pipes
// The server will create pipe instances on demand
info!("Starting IPC server at {:?}", self.config.socket_path);
// Mark as "started" - actual pipe creation happens in accept loop
self.listener = None; // We'll create pipes on demand
info!("IPC server started successfully");
Ok(())
}
/// Run the server loop.
#[cfg(target_os = "linux")]
pub async fn run(&self) -> Result<()> {
let listener = self.listener.as_ref().ok_or_else(|| {
IpcError::BindError(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"Server not started",
))
})?;
info!("IPC server listening for connections");
loop {
if *self.shutdown.read().await {
info!("IPC server shutting down");
break;
}
// Accept new connection
match listener.accept().await {
Ok((stream, addr)) => {
self.handle_new_connection(stream, format!("{:?}", addr)).await;
}
Err(e) => {
warn!("Failed to accept connection: {}", e);
}
}
}
Ok(())
}
/// Run the server loop (Windows).
#[cfg(target_os = "windows")]
pub async fn run(&self) -> Result<()> {
use tokio::net::windows::named_pipe::{ServerOptions, NamedPipeServer};
use std::os::windows::io::AsRawHandle;
info!("IPC server listening for connections on {:?}", self.config.socket_path);
let pipe_name = self.config.socket_path.to_string_lossy();
loop {
if *self.shutdown.read().await {
info!("IPC server shutting down");
break;
}
// Create a new named pipe instance
let server = ServerOptions::new()
.first_pipe_instance(false)
.reject_remote_clients(true)
.create(&pipe_name)
.map_err(IpcError::BindError)?;
// Wait for a client to connect
match server.connect().await {
Ok(()) => {
debug!("New IPC client connected");
// Spawn handler for this connection
let handlers = self.handlers.clone();
let shutdown = self.shutdown.clone();
let notification_tx = self.notification_tx.clone();
let client_count = self.client_count.clone();
let max_connections = self.config.max_connections;
// Check connection limit
let current_count = *client_count.read().await;
if current_count >= max_connections {
warn!("Connection limit reached, rejecting connection");
continue;
}
tokio::spawn(async move {
*client_count.write().await += 1;
if let Err(e) = Self::handle_connection_windows(
server,
handlers,
shutdown.clone(),
notification_tx,
)
.await
{
error!("Connection error: {}", e);
}
*client_count.write().await -= 1;
debug!("Client disconnected");
});
}
Err(e) => {
warn!("Failed to accept connection: {}", e);
}
}
}
Ok(())
}
/// Handle a new connection (Linux).
#[cfg(target_os = "linux")]
async fn handle_new_connection(&self, stream: PlatformStream, addr: String) {
let client_count = self.client_count.clone();
let max_connections = self.config.max_connections;
// Check connection limit
let current_count = *client_count.read().await;
if current_count >= max_connections {
warn!(
"Connection limit reached, rejecting connection from {}",
addr
);
return;
}
debug!("New IPC client connected from {}", addr);
// Spawn handler for this connection
let handlers = self.handlers.clone();
let shutdown = self.shutdown.clone();
let notification_tx = self.notification_tx.clone();
tokio::spawn(async move {
*client_count.write().await += 1;
if let Err(e) = Self::handle_connection(stream, handlers, shutdown.clone(), notification_tx).await
{
error!("Connection error: {}", e);
}
*client_count.write().await -= 1;
debug!("Client disconnected");
});
}
/// Handle a single client connection (Linux).
#[cfg(target_os = "linux")]
async fn handle_connection(
stream: PlatformStream,
handlers: Arc<IpcHandlers>,
shutdown: Arc<RwLock<bool>>,
_notification_tx: broadcast::Sender<IpcNotification>,
) -> Result<()> {
let framed = Framed::new(stream, LinesCodec::new());
let (mut writer, mut reader) = framed.split();
while !*shutdown.read().await {
// Read message
let line = tokio::time::timeout(std::time::Duration::from_secs(30), reader.next())
.await
.map_err(|_| {
IpcError::ReadError(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Read timeout",
))
})?;
let line = match line {
Some(Ok(line)) => line,
Some(Err(e)) => {
warn!("Error reading from client: {}", e);
break;
}
None => {
debug!("Client disconnected");
break;
}
};
trace!("Received: {}", line);
// Parse message
let message = match IpcMessage::from_json(&line) {
Ok(msg) => msg,
Err(e) => {
warn!("Failed to parse message: {}", e);
let response = IpcResponse::error(uuid::Uuid::nil(), format!("Parse error: {}", e));
writer.send(response.to_json()?).await
.map_err(|e| IpcError::CodecError(e.to_string()))?;
continue;
}
};
// Handle message
let response = match message.message_type {
MessageType::Request => {
handlers.handle(message).await
}
MessageType::Notification => {
// Notifications don't get responses
trace!("Received notification: {:?}", message);
continue;
}
MessageType::Response => {
// We shouldn't receive responses
warn!("Unexpected response message from client");
continue;
}
};
// Send response
let response_json = response.to_json()?;
writer.send(response_json).await
.map_err(|e| IpcError::CodecError(e.to_string()))?;
}
Ok(())
}
/// Handle a single client connection (Windows).
#[cfg(target_os = "windows")]
async fn handle_connection_windows(
server: tokio::net::windows::named_pipe::NamedPipeServer,
handlers: Arc<IpcHandlers>,
shutdown: Arc<RwLock<bool>>,
_notification_tx: broadcast::Sender<IpcNotification>,
) -> Result<()> {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let (mut reader, mut writer) = tokio::io::split(server);
let mut reader = BufReader::new(reader).lines();
while !*shutdown.read().await {
// Read message with timeout
let line = tokio::time::timeout(std::time::Duration::from_secs(30), reader.next_line())
.await
.map_err(|_| {
IpcError::ReadError(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Read timeout",
))
})?;
let line = match line {
Ok(Some(line)) => line,
Ok(None) => {
debug!("Client disconnected");
break;
}
Err(e) => {
warn!("Error reading from client: {}", e);
break;
}
};
trace!("Received: {}", line);
// Parse message
let message = match IpcMessage::from_json(&line) {
Ok(msg) => msg,
Err(e) => {
warn!("Failed to parse message: {}", e);
let response = IpcResponse::error(uuid::Uuid::nil(), format!("Parse error: {}", e));
writer.write_all(response.to_json()?.as_bytes()).await
.map_err(IpcError::WriteError)?;
writer.write_all(b"\n").await.map_err(IpcError::WriteError)?;
continue;
}
};
// Handle message
let response = match message.message_type {
MessageType::Request => {
handlers.handle(message).await
}
MessageType::Notification => {
// Notifications don't get responses
trace!("Received notification: {:?}", message);
continue;
}
MessageType::Response => {
// We shouldn't receive responses
warn!("Unexpected response message from client");
continue;
}
};
// Send response
let response_json = response.to_json()?;
writer.write_all(response_json.as_bytes()).await.map_err(IpcError::WriteError)?;
writer.write_all(b"\n").await.map_err(IpcError::WriteError)?;
}
Ok(())
}
/// Stop the IPC server.
pub async fn stop(&mut self) -> Result<()> {
*self.shutdown.write().await = true;
// Remove socket file (Linux only)
#[cfg(target_os = "linux")]
{
if self.config.socket_path.exists() {
std::fs::remove_file(&self.config.socket_path)
.map_err(IpcError::BindError)?;
}
}
info!("IPC server stopped");
Ok(())
}
}
impl Clone for IpcServer {
fn clone(&self) -> Self {
Self {
config: self.config.clone(),
listener: None, // Can't clone the listener
handlers: self.handlers.clone(),
notification_tx: self.notification_tx.clone(),
shutdown: self.shutdown.clone(),
client_count: self.client_count.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ipc_server_config_default() {
let config = IpcServerConfig::default();
assert!(config.socket_path.to_string_lossy().contains("record-daemon"));
assert_eq!(config.max_connections, 10);
assert_eq!(config.timeout_secs, 30);
}
}

27
record-daemon/src/lib.rs Normal file
View File

@@ -0,0 +1,27 @@
//! Record Daemon - High-performance League of Legends recording daemon.
//!
//! This daemon automatically records League of Legends matches using libobs,
//! captures game events via the League Client API (LQP), and exposes
//! configuration via a Unix socket IPC for the Tauri app.
pub mod config;
pub mod error;
pub mod ipc;
pub mod lqp;
pub mod recording;
pub mod state;
pub mod timeline;
pub use config::Settings;
pub use error::{DaemonError, Result};
pub use ipc::IpcServer;
pub use lqp::LqpClient;
pub use recording::RecordingEngine;
pub use state::{DaemonStateMachine, DaemonStatus};
pub use timeline::TimelineStore;
/// Daemon version.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Daemon name.
pub const NAME: &str = env!("CARGO_PKG_NAME");

View File

@@ -0,0 +1,340 @@
//! LQP authentication via League Client lockfile.
//!
//! The League Client writes a lockfile on startup containing connection credentials.
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use tracing::{debug, trace, warn};
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",
];
/// Credentials extracted from the League Client lockfile.
#[derive(Debug, Clone)]
pub struct LockfileCredentials {
/// Process name (usually "LeagueClient").
pub process_name: String,
/// Process ID.
pub pid: u32,
/// Port for the LQP API.
pub port: u16,
/// Password for authentication.
pub password: String,
/// Protocol (usually "https").
pub protocol: String,
}
impl LockfileCredentials {
/// Parse lockfile contents into credentials.
///
/// Lockfile format: `LeagueClient:PID:PORT:PASSWORD:PROTOCOL`
pub fn parse(contents: &str) -> Result<Self> {
let parts: Vec<&str> = contents.trim().split(':').collect();
if parts.len() != 5 {
return Err(LqpError::LockfileParseError(format!(
"Expected 5 fields, got {}",
parts.len()
))
.into());
}
let process_name = parts[0].to_string();
let pid = parts[1]
.parse::<u32>()
.map_err(|e| LqpError::LockfileParseError(format!("Invalid PID: {}", e)))?;
let port = parts[2]
.parse::<u16>()
.map_err(|e| LqpError::LockfileParseError(format!("Invalid port: {}", e)))?;
let password = parts[3].to_string();
let protocol = parts[4].to_string();
Ok(Self {
process_name,
pid,
port,
password,
protocol,
})
}
/// Get the base URL for the LQP REST API.
pub fn base_url(&self) -> String {
format!("{}://127.0.0.1:{}", self.protocol, self.port)
}
/// Get the WebSocket URL for the LQP WebSocket API.
pub fn ws_url(&self) -> String {
format!("wss://127.0.0.1:{}", self.port)
}
/// Get the Basic Auth header value.
pub fn auth_header(&self) -> String {
let credentials = format!("riot:{}", self.password);
let encoded =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, credentials);
format!("Basic {}", encoded)
}
}
/// Watcher for League Client lockfile.
///
/// Monitors for the League Client starting and stopping by watching
/// for the lockfile to appear/disappear.
pub struct LockfileWatcher {
/// Possible lockfile paths to check.
paths: Vec<PathBuf>,
/// Current credentials if client is running.
current: Option<LockfileCredentials>,
}
impl LockfileWatcher {
/// Create a new lockfile watcher with default paths.
pub fn new() -> Self {
let paths = Self::discover_lockfile_paths();
Self {
paths,
current: None,
}
}
/// Create a watcher with a specific lockfile path.
pub fn with_path(path: PathBuf) -> Self {
Self {
paths: vec![path],
current: None,
}
}
/// 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);
}
}
#[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()
}
/// Get current credentials if available.
pub fn credentials(&self) -> Option<&LockfileCredentials> {
self.current.as_ref()
}
/// Check for lockfile and update credentials.
///
/// Returns:
/// - `Some(true)` if client just started
/// - `Some(false)` if client just stopped
/// - `None` if no change
pub fn check(&mut self) -> Result<Option<bool>> {
// Try to find and read lockfile
let found = self.find_lockfile()?;
match (found, &self.current) {
(Some(creds), None) => {
// Client started
debug!(
"League Client detected (PID: {}, Port: {})",
creds.pid, creds.port
);
self.current = Some(creds);
Ok(Some(true))
}
(Some(new_creds), Some(old_creds)) => {
// Client still running, check if restarted
if new_creds.pid != old_creds.pid {
debug!(
"League Client restarted (PID: {} -> {})",
old_creds.pid, new_creds.pid
);
self.current = Some(new_creds);
// Don't report as change, just update
Ok(None)
} else {
// No change
Ok(None)
}
}
(None, Some(_)) => {
// Client stopped
debug!("League Client stopped");
self.current = None;
Ok(Some(false))
}
(None, None) => {
// Still not running
Ok(None)
}
}
}
/// Find and read the lockfile.
fn find_lockfile(&self) -> Result<Option<LockfileCredentials>> {
for path in &self.paths {
trace!("Checking lockfile path: {:?}", path);
if path.exists() {
match fs::read_to_string(path) {
Ok(contents) => match LockfileCredentials::parse(&contents) {
Ok(creds) => return Ok(Some(creds)),
Err(e) => {
warn!("Failed to parse lockfile at {:?}: {}", path, e);
continue;
}
},
Err(e) => {
trace!("Failed to read lockfile at {:?}: {}", path, e);
continue;
}
}
}
}
Ok(None)
}
/// Wait for the League Client to start.
///
/// Blocks until the client is detected or timeout is reached.
pub async fn wait_for_client(&mut self, timeout: Duration) -> Result<LockfileCredentials> {
let start = std::time::Instant::now();
loop {
if let Some(creds) = self.find_lockfile()? {
self.current = Some(creds.clone());
return Ok(creds);
}
if start.elapsed() >= timeout {
return Err(LqpError::Timeout.into());
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
}
impl Default for LockfileWatcher {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_lockfile() {
let contents = "LeagueClient:12345:52436:abc123:https";
let creds = LockfileCredentials::parse(contents).unwrap();
assert_eq!(creds.process_name, "LeagueClient");
assert_eq!(creds.pid, 12345);
assert_eq!(creds.port, 52436);
assert_eq!(creds.password, "abc123");
assert_eq!(creds.protocol, "https");
}
#[test]
fn test_base_url() {
let creds = LockfileCredentials {
process_name: "LeagueClient".to_string(),
pid: 12345,
port: 52436,
password: "abc123".to_string(),
protocol: "https".to_string(),
};
assert_eq!(creds.base_url(), "https://127.0.0.1:52436");
}
#[test]
fn test_auth_header() {
let creds = LockfileCredentials {
process_name: "LeagueClient".to_string(),
pid: 12345,
port: 52436,
password: "abc123".to_string(),
protocol: "https".to_string(),
};
// base64("riot:abc123") = "cmlvdDphYmMxMjM="
assert!(creds.auth_header().contains("Basic "));
}
}

View File

@@ -0,0 +1,423 @@
//! LQP Client for communicating with the League Client API.
//!
//! Provides both WebSocket (for events) and REST (for queries) interfaces.
use std::sync::Arc;
use futures::{SinkExt, StreamExt};
use tokio::sync::{broadcast, RwLock};
use tokio_tungstenite::{connect_async_with_config, tungstenite::protocol::Message};
use tracing::{debug, error, info, trace, warn};
use super::auth::LockfileCredentials;
use super::events::GameEvent;
use crate::error::{LqpError, Result};
/// LQP WebSocket endpoints to subscribe to.
const SUBSCRIBE_ENDPOINTS: &[&str] = &[
"/lol-gameflow/v1/gameflow-phase",
"/lol-matchmaking/v1/ready-check",
"/lol-game-events/v1/game-events",
];
/// LQP REST API endpoints.
pub mod endpoints {
pub const GAMEFLOW_PHASE: &str = "/lol-gameflow/v1/gameflow-phase";
pub const SESSION: &str = "/lol-gameflow/v1/session";
pub const CHAMPION_SELECT: &str = "/lol-champ-select/v1/session";
pub const SUMMONER: &str = "/lol-summoner/v1/current-summoner";
pub const GAME_STATS: &str = "/lol-end-of-game/v1/eog-stats-block";
}
/// Game flow phase states.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GameflowPhase {
/// Client is in main menu or lobby.
None,
/// In lobby.
Lobby,
/// In queue.
Queue,
/// Match found, ready check.
ReadyCheck,
/// In champion select.
ChampSelect,
/// Game is starting.
GameStart,
/// In game.
InProgress,
/// Game ended, waiting for stats.
WaitingForStats,
/// End of game stats screen.
EndOfGame,
/// Unknown phase.
Unknown,
}
impl From<&str> for GameflowPhase {
fn from(s: &str) -> Self {
match s {
"None" => GameflowPhase::None,
"Lobby" => GameflowPhase::Lobby,
"Queue" => GameflowPhase::Queue,
"ReadyCheck" => GameflowPhase::ReadyCheck,
"ChampSelect" => GameflowPhase::ChampSelect,
"GameStart" => GameflowPhase::GameStart,
"InProgress" => GameflowPhase::InProgress,
"WaitingForStats" => GameflowPhase::WaitingForStats,
"EndOfGame" => GameflowPhase::EndOfGame,
_ => GameflowPhase::Unknown,
}
}
}
/// LQP Client state.
#[derive(Debug, Clone)]
pub struct ClientState {
/// Current gameflow phase.
pub phase: GameflowPhase,
/// Current game ID if in game.
pub game_id: Option<u64>,
/// Current champion name.
pub champion: Option<String>,
}
impl Default for ClientState {
fn default() -> Self {
Self {
phase: GameflowPhase::None,
game_id: None,
champion: None,
}
}
}
/// LQP Client for League Client communication.
pub struct LqpClient {
/// Connection credentials.
credentials: Arc<RwLock<Option<LockfileCredentials>>>,
/// Current client state.
state: Arc<RwLock<ClientState>>,
/// Event broadcaster.
event_sender: broadcast::Sender<GameEvent>,
/// HTTP client for REST API.
http_client: reqwest::Client,
/// Shutdown signal.
shutdown: Arc<RwLock<bool>>,
}
impl LqpClient {
/// Create a new LQP client.
pub fn new() -> Self {
let (event_sender, _) = broadcast::channel(256);
let http_client = reqwest::Client::builder()
.danger_accept_invalid_certs(true) // LQP uses self-signed certs
.build()
.expect("Failed to create HTTP client");
Self {
credentials: Arc::new(RwLock::new(None)),
state: Arc::new(RwLock::new(ClientState::default())),
event_sender,
http_client,
shutdown: Arc::new(RwLock::new(false)),
}
}
/// Get a subscriber for game events.
pub fn subscribe(&self) -> broadcast::Receiver<GameEvent> {
self.event_sender.subscribe()
}
/// Get current client state.
pub async fn state(&self) -> ClientState {
self.state.read().await.clone()
}
/// Check if connected to League Client.
pub async fn is_connected(&self) -> bool {
self.credentials.read().await.is_some()
}
/// Connect to the League Client with the given credentials.
pub async fn connect(&self, creds: LockfileCredentials) -> Result<()> {
info!("Connecting to League Client at port {}", creds.port);
// Store credentials
*self.credentials.write().await = Some(creds.clone());
// Verify connection by fetching current phase
match self.get_gameflow_phase().await {
Ok(phase) => {
self.state.write().await.phase = phase;
info!("Connected to League Client, current phase: {:?}", phase);
}
Err(e) => {
warn!("Failed to verify connection: {}", e);
// Still consider connected, WebSocket might work
}
}
Ok(())
}
/// Disconnect from the League Client.
pub async fn disconnect(&self) {
*self.shutdown.write().await = true;
*self.credentials.write().await = None;
*self.state.write().await = ClientState::default();
info!("Disconnected from League Client");
}
/// Start the WebSocket event listener.
///
/// This runs in a background task and broadcasts events to subscribers.
pub async fn start_event_listener(&self) -> Result<()> {
let creds = self
.credentials
.read()
.await
.clone()
.ok_or(LqpError::ClientNotRunning)?;
let ws_url = format!("{}/", creds.ws_url());
let auth_header = creds.auth_header();
info!("Connecting to LQP WebSocket at {}", ws_url);
// Build WebSocket request with auth header
let request = tokio_tungstenite::tungstenite::http::Request::builder()
.uri(&ws_url)
.header("Authorization", auth_header)
.body(())
.map_err(|e| LqpError::ConnectionFailed(e.to_string()))?;
let (ws_stream, _) = connect_async_with_config(
request, None, false,
// None,
)
.await
.map_err(|e| LqpError::WebSocketError(e.to_string()))?;
info!("WebSocket connected, subscribing to events");
let (mut write, mut read) = ws_stream.split();
// Subscribe to endpoints
for endpoint in SUBSCRIBE_ENDPOINTS {
let subscribe_msg = serde_json::json!([5, endpoint]);
let msg = Message::Text(subscribe_msg.to_string());
write
.send(msg)
.await
.map_err(|e| LqpError::WebSocketError(e.to_string()))?;
trace!("Subscribed to {}", endpoint);
}
// Clone references for the async block
let event_sender = self.event_sender.clone();
let state = self.state.clone();
let shutdown = self.shutdown.clone();
let credentials = self.credentials.clone();
// Spawn the message handler
tokio::spawn(async move {
while let Some(msg) = read.next().await {
if *shutdown.read().await {
debug!("WebSocket listener shutting down");
break;
}
match msg {
Ok(Message::Text(text)) => {
if let Some(event) = Self::parse_websocket_message(&text) {
// Update state based on event
Self::update_state_from_event(&state, &event).await;
// Broadcast event
if event_sender.send(event.clone()).is_err() {
trace!("No event subscribers");
}
}
}
Ok(Message::Close(_)) => {
info!("WebSocket closed by server");
break;
}
Ok(Message::Ping(data)) => {
// Respond with pong
let _ = write.send(Message::Pong(data)).await;
}
Err(e) => {
error!("WebSocket error: {}", e);
break;
}
_ => {}
}
}
// Clear credentials on disconnect
*credentials.write().await = None;
debug!("WebSocket listener ended");
});
Ok(())
}
/// Parse a WebSocket message into a game event.
fn parse_websocket_message(text: &str) -> Option<GameEvent> {
trace!("WebSocket message: {}", text);
// Parse the message array format: [type, endpoint, data]
let value: serde_json::Value = serde_json::from_str(text).ok()?;
// Check if it's an event message (type 8)
if let Some(arr) = value.as_array() {
if arr.len() >= 3 {
let msg_type = arr.first()?.as_u64()?;
if msg_type == 8 {
// Event message
let endpoint = arr.get(1)?.as_str()?;
let data = arr.get(2)?;
return Self::parse_event_from_endpoint(endpoint, data);
}
}
}
None
}
/// Parse an event based on the endpoint.
fn parse_event_from_endpoint(endpoint: &str, data: &serde_json::Value) -> Option<GameEvent> {
match endpoint {
"/lol-gameflow/v1/gameflow-phase" => {
let phase = data.as_str()?;
Some(
GameEvent::from_json(&serde_json::json!({
"eventType": "lcu-phase-change",
"phase": phase
}))
.unwrap_or(GameEvent::Unknown),
)
}
"/lol-game-events/v1/game-events" => GameEvent::from_json(data),
_ => {
trace!("Unhandled endpoint: {}", endpoint);
None
}
}
}
/// Update internal state from a game event.
async fn update_state_from_event(state: &Arc<RwLock<ClientState>>, event: &GameEvent) {
let mut state = state.write().await;
match event {
GameEvent::GameStart(info) => {
state.phase = GameflowPhase::InProgress;
state.game_id = Some(info.game_id);
state.champion = info.champion.clone();
}
GameEvent::GameEnd(_) => {
state.phase = GameflowPhase::EndOfGame;
}
_ => {}
}
}
/// Make a REST API request to the League Client.
pub async fn request(&self, method: &str, endpoint: &str) -> Result<serde_json::Value> {
let creds = self
.credentials
.read()
.await
.clone()
.ok_or(LqpError::ClientNotRunning)?;
let url = format!("{}{}", creds.base_url(), endpoint);
let request = match method {
"GET" => self.http_client.get(&url),
"POST" => self.http_client.post(&url),
"PUT" => self.http_client.put(&url),
"DELETE" => self.http_client.delete(&url),
_ => {
return Err(
LqpError::ConnectionFailed(format!("Invalid method: {}", method)).into(),
)
}
}
.header("Authorization", creds.auth_header());
let response = request
.send()
.await
.map_err(|e| LqpError::ConnectionFailed(e.to_string()))?;
if !response.status().is_success() {
return Err(LqpError::ConnectionFailed(format!(
"API request failed: {}",
response.status()
))
.into());
}
let json = response
.json()
.await
.map_err(|e| LqpError::EventParseError(e.to_string()))?;
Ok(json)
}
/// Get the current gameflow phase.
pub async fn get_gameflow_phase(&self) -> Result<GameflowPhase> {
let json = self.request("GET", endpoints::GAMEFLOW_PHASE).await?;
let phase_str = json
.as_str()
.ok_or_else(|| LqpError::EventParseError("Invalid phase response".to_string()))?;
Ok(GameflowPhase::from(phase_str))
}
/// Get the current game session info.
pub async fn get_session(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::SESSION).await
}
/// Get current summoner info.
pub async fn get_summoner(&self) -> Result<serde_json::Value> {
self.request("GET", endpoints::SUMMONER).await
}
}
impl Default for LqpClient {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gameflow_phase_from_str() {
assert_eq!(GameflowPhase::from("InProgress"), GameflowPhase::InProgress);
assert_eq!(
GameflowPhase::from("ChampSelect"),
GameflowPhase::ChampSelect
);
assert_eq!(GameflowPhase::from("UnknownPhase"), GameflowPhase::Unknown);
}
#[test]
fn test_client_creation() {
let client = LqpClient::new();
assert!(!tokio_test::block_on(client.is_connected()));
}
}

View File

@@ -0,0 +1,346 @@
//! Game event types from the League Client API.
//!
//! These events are received via WebSocket subscription to LQP endpoints.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// A game event received from the League Client.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "eventType", rename_all = "camelCase")]
pub enum GameEvent {
/// Match found in queue.
#[serde(rename = "lcu-match-found")]
MatchFound(MatchInfo),
/// Game has started.
#[serde(rename = "lcu-game-start")]
GameStart(GameStartInfo),
/// Player killed an enemy.
#[serde(rename = "lcu-kill")]
Kill(KillEvent),
/// Player died.
#[serde(rename = "lcu-death")]
Death(DeathEvent),
/// Objective was taken.
#[serde(rename = "lcu-objective")]
Objective(ObjectiveEvent),
/// Game has ended.
#[serde(rename = "lcu-game-end")]
GameEnd(GameEndInfo),
/// Unknown event type.
#[serde(other)]
Unknown,
}
/// Match found event data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MatchInfo {
/// Match ID.
#[serde(default)]
pub match_id: Option<String>,
/// Queue type (ranked, normal, aram, etc.).
pub queue_type: String,
/// Map name.
pub map: String,
/// Game mode.
pub game_mode: String,
/// Timestamp when match was found.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// Game start event data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GameStartInfo {
/// Game ID.
pub game_id: u64,
/// Server address.
#[serde(default)]
pub server: Option<String>,
/// Player's champion name.
#[serde(default)]
pub champion: Option<String>,
/// Player's summoner name.
#[serde(default)]
pub summoner_name: Option<String>,
/// Team (100 = blue, 200 = red).
#[serde(default)]
pub team: Option<u32>,
/// Game start timestamp.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// Kill event data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KillEvent {
/// Killer summoner name.
pub killer: String,
/// Killer champion name.
#[serde(default)]
pub killer_champion: Option<String>,
/// Victim summoner name.
pub victim: String,
/// Victim champion name.
#[serde(default)]
pub victim_champion: Option<String>,
/// Whether this was a solo kill.
#[serde(default)]
pub solo_kill: bool,
/// Number of assists.
#[serde(default)]
pub assists: u32,
/// Kill position on map.
#[serde(default)]
pub position: Option<Position>,
/// Game time when kill occurred.
#[serde(default)]
pub game_time: Option<f64>,
/// Real timestamp.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// Death event data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeathEvent {
/// Killer summoner name (if killed by champion).
#[serde(default)]
pub killer: Option<String>,
/// Killer champion name.
#[serde(default)]
pub killer_champion: Option<String>,
/// Death cause (champion, minion, tower, etc.).
pub cause: String,
/// Death position on map.
#[serde(default)]
pub position: Option<Position>,
/// Game time when death occurred.
#[serde(default)]
pub game_time: Option<f64>,
/// Real timestamp.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// Objective event data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ObjectiveEvent {
/// Type of objective.
pub objective_type: ObjectiveType,
/// Team that took the objective (100 = blue, 200 = red).
pub team: u32,
/// Whether the player participated.
#[serde(default)]
pub participated: bool,
/// Game time when objective was taken.
#[serde(default)]
pub game_time: Option<f64>,
/// Real timestamp.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// Type of objective.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ObjectiveType {
Dragon,
Herald,
Baron,
Tower,
Inhibitor,
Nexus,
RiftHerald,
ElderDragon,
}
/// 2D position on the map.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Position {
pub x: f32,
pub y: f32,
}
/// Game end event data.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GameEndInfo {
/// Game ID.
pub game_id: u64,
/// Whether the player's team won.
pub victory: bool,
/// Game duration in seconds.
pub duration: f64,
/// Player's stats.
#[serde(default)]
pub stats: Option<PlayerStats>,
/// End timestamp.
#[serde(default = "Utc::now")]
pub timestamp: DateTime<Utc>,
}
/// Player statistics at game end.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayerStats {
/// Kills.
pub kills: u32,
/// Deaths.
pub deaths: u32,
/// Assists.
pub assists: u32,
/// Total gold earned.
pub gold_earned: u32,
/// Total damage dealt.
pub damage_dealt: u64,
/// Total damage taken.
pub damage_taken: u64,
/// Minions killed (CS).
pub minions_killed: u32,
/// Vision score.
#[serde(default)]
pub vision_score: f64,
}
/// Raw event data from LQP WebSocket.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawEvent {
/// Event type URI.
#[serde(rename = "uri")]
pub uri: String,
/// Event data.
pub data: EventData,
}
/// Event data payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum EventData {
/// Game event.
GameEvent(GameEvent),
/// Raw JSON value.
Raw(serde_json::Value),
}
impl GameEvent {
/// Parse a game event from raw WebSocket data.
pub fn from_json(value: &serde_json::Value) -> Option<Self> {
serde_json::from_value(value.clone()).ok()
}
/// Check if this event is relevant for recording.
pub fn is_relevant(&self) -> bool {
!matches!(self, GameEvent::Unknown)
}
/// Get a human-readable description of the event.
pub fn description(&self) -> String {
match self {
GameEvent::MatchFound(info) => {
format!("Match found: {} ({})", info.game_mode, info.queue_type)
}
GameEvent::GameStart(info) => {
format!("Game started: ID {}", info.game_id)
}
GameEvent::Kill(kill) => {
format!("{} killed {}", kill.killer, kill.victim)
}
GameEvent::Death(death) => {
format!("Player died ({})", death.cause)
}
GameEvent::Objective(obj) => {
let team = if obj.team == 100 { "Blue" } else { "Red" };
format!("{} took {:?}", team, obj.objective_type)
}
GameEvent::GameEnd(end) => {
let result = if end.victory { "Victory" } else { "Defeat" };
format!("Game ended: {} ({:.1}s)", result, end.duration)
}
GameEvent::Unknown => "Unknown event".to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_kill_event() {
let json = serde_json::json!({
"eventType": "lcu-kill",
"killer": "Player1",
"killerChampion": "Ahri",
"victim": "Player2",
"victimChampion": "Lux",
"soloKill": true,
"assists": 0
});
let event: GameEvent = serde_json::from_value(json).unwrap();
if let GameEvent::Kill(kill) = event {
assert_eq!(kill.killer, "Player1");
assert!(kill.solo_kill);
} else {
panic!("Expected Kill event");
}
}
#[test]
fn test_objective_type_deserialization() {
let json = serde_json::json!("dragon");
let obj: ObjectiveType = serde_json::from_value(json).unwrap();
assert_eq!(obj, ObjectiveType::Dragon);
}
}

View File

@@ -0,0 +1,15 @@
//! League Client API (LQP) module.
//!
//! This module handles communication with the League of Legends client
//! via WebSocket and REST API for game event detection and capture.
mod auth;
mod client;
mod events;
pub use auth::{LockfileCredentials, LockfileWatcher};
pub use client::{GameflowPhase, LqpClient};
pub use events::{
DeathEvent, EventData, GameEndInfo, GameEvent, GameStartInfo, KillEvent, MatchInfo,
ObjectiveEvent, ObjectiveType,
};

355
record-daemon/src/main.rs Normal file
View File

@@ -0,0 +1,355 @@
//! Record Daemon entry point.
use std::sync::Arc;
use clap::Parser;
use parking_lot::RwLock;
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,
ipc::{self, IpcHandlers, IpcServer, IpcServerConfig},
lqp::{GameEvent, LockfileWatcher, LqpClient},
recording::RecordingEngine,
state::{DaemonStateMachine, DaemonStatus, StateTransition},
timeline::{EventMapper, TimelineStore},
};
/// Record Daemon - League of Legends recording daemon.
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
/// Path to configuration file.
#[arg(short, long, value_name = "PATH")]
config: Option<std::path::PathBuf>,
/// Log level (trace, debug, info, warn, error).
#[arg(short, long, default_value = "info")]
log_level: String,
/// Run in foreground (don't daemonize).
#[arg(short, long)]
foreground: bool,
/// Socket path for IPC.
#[arg(short, long)]
socket: Option<std::path::PathBuf>,
}
/// Main daemon structure.
struct Daemon {
/// Configuration.
settings: Arc<RwLock<Settings>>,
/// State machine.
state_machine: Arc<DaemonStateMachine>,
/// LQP client.
lqp_client: Arc<LqpClient>,
/// Recording engine.
recording_engine: Arc<RwLock<Option<RecordingEngine>>>,
/// Timeline store.
timeline_store: Arc<RwLock<TimelineStore>>,
/// Event mapper.
event_mapper: Arc<RwLock<EventMapper>>,
/// IPC server.
ipc_server: Option<IpcServer>,
/// Shutdown signal.
shutdown_tx: broadcast::Sender<()>,
}
impl Daemon {
/// Create a new daemon instance.
fn new(settings: Settings) -> Self {
let (shutdown_tx, _) = broadcast::channel(1);
Self {
settings: Arc::new(RwLock::new(settings.clone())),
state_machine: Arc::new(DaemonStateMachine::new()),
lqp_client: Arc::new(LqpClient::new()),
recording_engine: Arc::new(RwLock::new(None)),
timeline_store: Arc::new(RwLock::new(TimelineStore::new())),
event_mapper: Arc::new(RwLock::new(EventMapper::new())),
ipc_server: None,
shutdown_tx,
}
}
/// Initialize the daemon.
async fn init(&mut self) -> Result<()> {
info!("Initializing record daemon v{}", record_daemon::VERSION);
// Initialize recording engine
let settings = self.settings.read().clone();
let mut engine = RecordingEngine::new(settings);
engine.initialize()?;
*self.recording_engine.write() = Some(engine);
// Load existing recordings from disk
self.timeline_store.read().load_from_disk()?;
// Initialize IPC server
let ipc_config = IpcServerConfig {
socket_path: self
.settings
.read()
.daemon
.socket_path
.clone()
.unwrap_or_else(ipc::default_socket_path),
..Default::default()
};
let handlers = IpcHandlers::new(
self.settings.clone(),
self.recording_engine.clone(),
self.timeline_store.clone(),
Arc::new(RwLock::new(DaemonStatus::Idle)),
Arc::new(RwLock::new(false)),
);
let mut ipc_server = IpcServer::new(ipc_config, handlers);
ipc_server.start().await?;
self.ipc_server = Some(ipc_server);
info!("Daemon initialized successfully");
Ok(())
}
/// Run the main daemon loop.
async fn run(&mut self) -> Result<()> {
info!("Starting main daemon loop");
let mut shutdown_rx = self.shutdown_tx.subscribe();
let mut lockfile_watcher = LockfileWatcher::new();
// Spawn IPC server task - take ownership
if let Some(ipc_server) = self.ipc_server.take() {
tokio::spawn(async move {
if let Err(e) = ipc_server.run().await {
error!("IPC server error: {}", e);
}
});
}
// Subscribe to LQP events before the loop
let mut event_rx = self.lqp_client.subscribe();
// Main event loop
loop {
tokio::select! {
// Shutdown signal
_ = shutdown_rx.recv() => {
info!("Shutdown signal received");
break;
}
// Check for League Client
result = self.check_client(&mut lockfile_watcher) => {
if let Err(e) = result {
warn!("Client check error: {}", e);
}
}
// Process LQP events
event = event_rx.recv() => {
if let Ok(event) = event {
if let Err(e) = self.handle_game_event(event).await {
warn!("Event handling error: {}", e);
}
}
}
}
}
info!("Daemon loop ended");
Ok(())
}
/// Check for League Client connection.
async fn check_client(&self, watcher: &mut LockfileWatcher) -> Result<()> {
let poll_interval =
std::time::Duration::from_millis(self.settings.read().daemon.poll_interval_ms);
match watcher.check()? {
Some(true) => {
// Client started
info!("League Client detected");
self.state_machine
.transition(StateTransition::ClientStarted);
if let Some(creds) = watcher.credentials() {
self.lqp_client.connect(creds.clone()).await?;
self.lqp_client.start_event_listener().await?;
}
}
Some(false) => {
// Client stopped
info!("League Client stopped");
self.state_machine
.transition(StateTransition::ClientStopped);
self.lqp_client.disconnect().await;
}
None => {}
}
tokio::time::sleep(poll_interval).await;
Ok(())
}
/// Handle a game event.
async fn handle_game_event(&self, event: GameEvent) -> Result<()> {
debug!("Game event: {:?}", event);
// Process state transitions
if let Some(transition) = self.state_machine.process_event(&event) {
self.state_machine.transition(transition.clone());
// Handle recording start/stop
match transition {
StateTransition::GameStarted { game_id, champion } => {
self.start_recording(game_id, champion.as_deref()).await?;
}
StateTransition::GameEnded => {
self.stop_recording().await?;
}
_ => {}
}
}
// Record event to timeline if recording
if self.state_machine.is_recording() {
if let Some((video_ts, game_ts)) = self.event_mapper.write().handle_event(&event) {
// Event would be added to timeline here
debug!(
"Event mapped: video_ts={:?}, game_ts={:?}",
video_ts, game_ts
);
}
}
Ok(())
}
/// Start recording.
async fn start_recording(&self, game_id: u64, champion: Option<&str>) -> Result<()> {
info!("Starting recording for game {} ({:?})", game_id, champion);
let mut engine_guard = self.recording_engine.write();
if let Some(ref mut engine) = *engine_guard {
engine.start_recording(Some(game_id), champion)?;
self.event_mapper.write().start();
}
Ok(())
}
/// Stop recording.
async fn stop_recording(&self) -> Result<()> {
info!("Stopping recording");
let mut engine_guard = self.recording_engine.write();
if let Some(ref mut engine) = *engine_guard {
let result = engine.stop_recording()?;
self.event_mapper.write().stop();
// Save to timeline
self.timeline_store.write().add_recording(result)?;
}
Ok(())
}
/// Shutdown the daemon.
async fn shutdown(&mut self) -> Result<()> {
info!("Shutting down daemon");
// Stop recording if active
if self.state_machine.is_recording() {
self.stop_recording().await?;
}
// Stop IPC server
if let Some(ref mut ipc_server) = self.ipc_server {
ipc_server.stop().await?;
}
// Shutdown recording engine
if let Some(ref mut engine) = *self.recording_engine.write() {
engine.shutdown()?;
}
info!("Daemon shutdown complete");
Ok(())
}
}
/// Initialize logging.
fn init_logging(level: &str) {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(level));
tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer())
.init();
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
// Initialize logging
init_logging(&args.log_level);
info!("Record Daemon v{} starting", record_daemon::VERSION);
// Load configuration
let settings = if let Some(config_path) = args.config {
config::ConfigPersistence::new(config_path).load()?
} else {
config::load_config()?
};
// Create and run daemon
let mut daemon = Daemon::new(settings);
// Handle shutdown signals
let shutdown_tx = daemon.shutdown_tx.clone();
#[cfg(unix)]
{
use signal_hook::consts::signal::*;
use signal_hook_tokio::Signals;
let mut signals = Signals::new([SIGTERM, SIGINT, SIGQUIT])?;
let handle = signals.handle();
let tx = shutdown_tx.clone();
tokio::spawn(async move {
if let Some(signal) = signals.next().await {
info!("Received signal {:?}", signal);
let _ = tx.send(());
}
});
}
// Initialize and run
if let Err(e) = daemon.init().await {
error!("Failed to initialize daemon: {}", e);
return Err(e);
}
// Run main loop
let result = daemon.run().await;
// Cleanup
if let Err(e) = daemon.shutdown().await {
error!("Error during shutdown: {}", e);
}
result
}

View File

@@ -0,0 +1,293 @@
//! Game capture source configuration.
use serde::{Deserialize, Serialize};
use tracing::{debug, info};
use crate::error::Result;
/// Game capture source for recording game footage.
#[derive(Debug, Clone)]
pub struct GameCapture {
/// Source name.
pub name: String,
/// Window title to capture (if specific window).
pub window: Option<String>,
/// Process name to capture.
pub process_name: Option<String>,
/// Capture mode.
pub mode: CaptureMode,
/// Whether to capture cursor.
pub capture_cursor: bool,
}
impl Default for GameCapture {
fn default() -> Self {
Self {
name: "Game Capture".to_string(),
window: None,
process_name: Some("League of Legends.exe".to_string()),
mode: CaptureMode::Any,
capture_cursor: false,
}
}
}
impl GameCapture {
/// Create a new game capture source.
pub fn new(name: &str) -> Self {
Self {
name: name.to_string(),
..Default::default()
}
}
/// Set the window to capture.
pub fn with_window(mut self, window: &str) -> Self {
self.window = Some(window.to_string());
self.mode = CaptureMode::Window;
self
}
/// Set the process name to capture.
pub fn with_process(mut self, process: &str) -> Self {
self.process_name = Some(process.to_string());
self.mode = CaptureMode::Process;
self
}
/// Set whether to capture the cursor.
pub fn with_cursor(mut self, capture: bool) -> Self {
self.capture_cursor = capture;
self
}
/// Create the OBS source.
///
/// Note: This would create the actual obs_source_t in libobs.
pub fn create_source(&self) -> Result<CaptureSource> {
info!("Creating game capture source: {}", self.name);
// Note: Actual libobs source creation would happen here
// obs_source_create("game_capture", name, settings, nullptr)
let source = CaptureSource {
name: self.name.clone(),
source_type: SourceType::GameCapture,
active: false,
};
debug!("Game capture source created");
Ok(source)
}
}
/// Capture mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CaptureMode {
/// Capture any fullscreen application.
Any,
/// Capture a specific window.
Window,
/// Capture a specific process.
Process,
}
/// Capture source abstraction.
#[derive(Debug, Clone)]
pub struct CaptureSource {
/// Source name.
pub name: String,
/// Source type.
pub source_type: SourceType,
/// Whether the source is active.
pub active: bool,
}
impl CaptureSource {
/// Check if the source is active.
pub fn is_active(&self) -> bool {
self.active
}
/// Activate the source.
pub fn activate(&mut self) -> Result<()> {
if self.active {
return Ok(());
}
debug!("Activating capture source: {}", self.name);
// Note: Actual activation would involve obs_source_set_enabled
self.active = true;
Ok(())
}
/// Deactivate the source.
pub fn deactivate(&mut self) -> Result<()> {
if !self.active {
return Ok(());
}
debug!("Deactivating capture source: {}", self.name);
// Note: Actual deactivation would involve obs_source_set_enabled
self.active = false;
Ok(())
}
}
/// Source type.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceType {
/// Game capture source.
GameCapture,
/// Window capture source.
WindowCapture,
/// Monitor capture source.
MonitorCapture,
}
/// Window capture source (alternative to game capture).
#[derive(Debug, Clone)]
pub struct WindowCapture {
/// Source name.
pub name: String,
/// Window title.
pub window_title: String,
/// Window class (X11).
pub window_class: Option<String>,
/// Whether to capture cursor.
pub capture_cursor: bool,
}
impl WindowCapture {
/// Create a new window capture source.
pub fn new(name: &str, window_title: &str) -> Self {
Self {
name: name.to_string(),
window_title: window_title.to_string(),
window_class: None,
capture_cursor: false,
}
}
/// Set the window class (for X11).
pub fn with_class(mut self, class: &str) -> Self {
self.window_class = Some(class.to_string());
self
}
/// Create the OBS source.
pub fn create_source(&self) -> Result<CaptureSource> {
info!(
"Creating window capture source: {} ({})",
self.name, self.window_title
);
let source = CaptureSource {
name: self.name.clone(),
source_type: SourceType::WindowCapture,
active: false,
};
Ok(source)
}
}
/// Monitor capture source (fallback).
#[derive(Debug, Clone)]
pub struct MonitorCapture {
/// Source name.
pub name: String,
/// Monitor index.
pub monitor: u32,
/// Whether to capture cursor.
pub capture_cursor: bool,
}
impl MonitorCapture {
/// Create a new monitor capture source.
pub fn new(name: &str, monitor: u32) -> Self {
Self {
name: name.to_string(),
monitor,
capture_cursor: false,
}
}
/// Create the OBS source.
pub fn create_source(&self) -> Result<CaptureSource> {
info!(
"Creating monitor capture source: {} (monitor {})",
self.name, self.monitor
);
let source = CaptureSource {
name: self.name.clone(),
source_type: SourceType::MonitorCapture,
active: false,
};
Ok(source)
}
}
/// Find the League of Legends game window.
pub fn find_league_window() -> Option<String> {
// Note: Actual window finding would use platform-specific APIs
// On Linux: X11/Wayland
// On Windows: Win32 API
#[cfg(target_os = "linux")]
{
// Would use x11rb or similar to find window
// For now, return the expected window title
Some("League of Legends (TM) Client".to_string())
}
#[cfg(target_os = "windows")]
{
// Would use FindWindowW
Some("League of Legends (TM) Client".to_string())
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_game_capture_creation() {
let capture = GameCapture::new("Test Capture");
assert_eq!(capture.name, "Test Capture");
assert_eq!(capture.mode, CaptureMode::Any);
}
#[test]
fn test_game_capture_with_process() {
let capture = GameCapture::new("Test").with_process("League of Legends.exe");
assert_eq!(
capture.process_name,
Some("League of Legends.exe".to_string())
);
assert_eq!(capture.mode, CaptureMode::Process);
}
#[test]
fn test_capture_source_activation() {
let mut source = CaptureSource {
name: "Test".to_string(),
source_type: SourceType::GameCapture,
active: false,
};
assert!(!source.is_active());
source.activate().unwrap();
assert!(source.is_active());
source.deactivate().unwrap();
assert!(!source.is_active());
}
}

View File

@@ -0,0 +1,303 @@
//! Video and audio encoder configuration.
use serde::{Deserialize, Serialize};
use crate::config::{AmfQuality, EncoderPreset, QualityLevel};
/// Video encoder configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EncoderConfig {
/// Encoder ID (e.g., "jim_nvenc", "x264").
pub encoder_id: String,
/// Target bitrate in kbps.
pub bitrate: u32,
/// Keyframe interval in seconds.
pub keyframe_interval: u32,
/// Encoder-specific settings.
pub settings: EncoderSettings,
}
/// Encoder-specific settings.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum EncoderSettings {
/// NVIDIA NVENC settings.
Nvenc {
/// Constant Quality level (0-51, lower = better).
cq_level: u32,
/// Use two-pass encoding.
two_pass: bool,
/// Preset (p1-p7, lower = faster).
preset: String,
/// Rate control mode.
rate_control: NvencRateControl,
},
/// AMD AMF settings.
Amf {
/// Quality preset.
quality: AmfQuality,
/// Rate control method.
rate_control: AmfRateControl,
},
/// x264 settings.
X264 {
/// Preset name.
preset: String,
/// Constant Rate Factor (if using CRF).
crf: Option<u32>,
/// Use threads.
threads: u32,
},
}
/// NVENC rate control modes.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum NvencRateControl {
/// Constant bitrate.
Cbr,
/// Variable bitrate.
#[default]
Vbr,
/// Constant quality.
Cqp,
}
/// AMF rate control modes.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum AmfRateControl {
/// Constant bitrate.
Cbr,
/// Variable bitrate.
#[default]
Vbr,
/// Constant QP.
Cqp,
}
impl EncoderConfig {
/// Create encoder config from preset.
pub fn from_preset(preset: &EncoderPreset) -> Self {
match preset {
EncoderPreset::Nvenc {
bitrate,
cq_level,
two_pass,
} => Self {
encoder_id: "jim_nvenc".to_string(),
bitrate: *bitrate,
keyframe_interval: 2,
settings: EncoderSettings::Nvenc {
cq_level: *cq_level,
two_pass: *two_pass,
preset: "p4".to_string(), // Balanced preset
rate_control: NvencRateControl::Vbr,
},
},
EncoderPreset::Amf { bitrate, quality } => Self {
encoder_id: "amd_amf_h264".to_string(),
bitrate: *bitrate,
keyframe_interval: 2,
settings: EncoderSettings::Amf {
quality: *quality,
rate_control: AmfRateControl::Vbr,
},
},
EncoderPreset::X264 {
preset,
bitrate,
crf,
} => Self {
encoder_id: "x264".to_string(),
bitrate: *bitrate,
keyframe_interval: 2,
settings: EncoderSettings::X264 {
preset: preset.clone(),
crf: *crf,
threads: 0, // Auto
},
},
}
}
/// Get the encoder name for display.
pub fn encoder_name(&self) -> &str {
&self.encoder_id
}
/// Check if this is a hardware encoder.
pub fn is_hardware(&self) -> bool {
matches!(
self.settings,
EncoderSettings::Nvenc { .. } | EncoderSettings::Amf { .. }
)
}
}
/// Video encoder trait for abstraction over different encoders.
pub trait VideoEncoder {
/// Get the encoder ID.
fn id(&self) -> &str;
/// Get the current bitrate.
fn bitrate(&self) -> u32;
/// Update the bitrate.
fn set_bitrate(&mut self, bitrate: u32);
/// Get encoder-specific settings as JSON.
fn settings_json(&self) -> serde_json::Value;
}
/// Audio encoder configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioEncoderConfig {
/// Encoder ID (usually "ffmpeg_aac").
pub encoder_id: String,
/// Bitrate in kbps.
pub bitrate: u32,
/// Sample rate in Hz.
pub sample_rate: u32,
/// Number of channels.
pub channels: u32,
}
impl Default for AudioEncoderConfig {
fn default() -> Self {
Self {
encoder_id: "ffmpeg_aac".to_string(),
bitrate: 192,
sample_rate: 48000,
channels: 2,
}
}
}
/// Audio encoder trait.
pub trait AudioEncoder {
/// Get the encoder ID.
fn id(&self) -> &str;
/// Get the current bitrate.
fn bitrate(&self) -> u32;
/// Update the bitrate.
fn set_bitrate(&mut self, bitrate: u32);
}
/// Detect available hardware encoders.
pub fn detect_hardware_encoders() -> Vec<EncoderCapability> {
let mut capabilities = Vec::new();
// Note: Actual detection would query the system for GPU availability
// For now, we check environment variables and common indicators
#[cfg(target_os = "linux")]
{
// Check for NVIDIA
if std::path::Path::new("/dev/nvidia0").exists() {
capabilities.push(EncoderCapability::Nvenc);
}
// Check for AMD
if std::path::Path::new("/sys/class/drm").exists() {
// Would check for AMD GPU
// capabilities.push(EncoderCapability::Amf);
}
}
#[cfg(target_os = "windows")]
{
// On Windows, would use DXGI to detect GPUs
// For now, assume software encoding
}
// Always available
capabilities.push(EncoderCapability::Software);
capabilities
}
/// Encoder capability.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EncoderCapability {
/// NVIDIA NVENC.
Nvenc,
/// AMD AMF.
Amf,
/// Intel QuickSync.
QuickSync,
/// Software (x264).
Software,
}
impl EncoderCapability {
/// Get the recommended encoder preset for this capability.
pub fn recommended_preset(&self, quality: QualityLevel) -> EncoderPreset {
let bitrate = quality.recommended_bitrate();
match self {
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_encoder_config_from_nvenc_preset() {
let preset = EncoderPreset::Nvenc {
bitrate: 8000,
cq_level: 20,
two_pass: true,
};
let config = EncoderConfig::from_preset(&preset);
assert_eq!(config.encoder_id, "jim_nvenc");
assert_eq!(config.bitrate, 8000);
assert!(config.is_hardware());
}
#[test]
fn test_encoder_config_from_x264_preset() {
let preset = EncoderPreset::X264 {
preset: "fast".to_string(),
bitrate: 6000,
crf: Some(23),
};
let config = EncoderConfig::from_preset(&preset);
assert_eq!(config.encoder_id, "x264");
assert!(!config.is_hardware());
}
#[test]
fn test_detect_hardware_encoders() {
let capabilities = detect_hardware_encoders();
assert!(!capabilities.is_empty());
assert!(capabilities.contains(&EncoderCapability::Software));
}
}

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

View File

@@ -0,0 +1,360 @@
//! OBS context initialization and management.
//!
//! This module handles the lifecycle of the OBS library context.
use std::path::Path;
use tracing::{debug, info, warn};
use crate::config::{AudioSettings, VideoSettings};
use crate::error::{RecordingError, Result};
/// OBS video settings for initialization.
#[derive(Debug, Clone)]
pub struct ObsVideoInfo {
/// Graphics adapter index (-1 for default).
pub adapter: i32,
/// Output resolution width.
pub output_width: u32,
/// Output resolution height.
pub output_height: u32,
/// Frames per second numerator.
pub fps_num: u32,
/// Frames per second denominator.
pub fps_den: u32,
/// Base resolution width.
pub base_width: u32,
/// Base resolution height.
pub base_height: u32,
/// Output format.
pub output_format: ObsVideoFormat,
}
impl Default for ObsVideoInfo {
fn default() -> Self {
Self {
adapter: -1,
output_width: 1920,
output_height: 1080,
fps_num: 60,
fps_den: 1,
base_width: 1920,
base_height: 1080,
output_format: ObsVideoFormat::Nv12,
}
}
}
impl ObsVideoInfo {
/// Create video info from settings.
pub fn from_settings(settings: &VideoSettings) -> Self {
let (width, height) = settings.quality.resolution();
let fps = settings.frame_rate;
Self {
adapter: -1,
output_width: width,
output_height: height,
fps_num: fps,
fps_den: 1,
base_width: width,
base_height: height,
output_format: ObsVideoFormat::Nv12,
}
}
}
/// Video output format.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObsVideoFormat {
/// NV12 format (common for hardware encoders).
Nv12,
/// I420 format.
I420,
/// I444 format.
I444,
/// RGBA format.
Rgba,
}
/// Builder for OBS context.
pub struct ObsContextBuilder {
video_settings: Option<VideoSettings>,
audio_settings: Option<AudioSettings>,
output_dir: Option<std::path::PathBuf>,
}
impl ObsContextBuilder {
/// Create a new OBS context builder.
pub fn new() -> Self {
Self {
video_settings: None,
audio_settings: None,
output_dir: None,
}
}
/// Set video settings.
pub fn with_video_settings(mut self, settings: VideoSettings) -> Self {
self.video_settings = Some(settings);
self
}
/// Set audio settings.
pub fn with_audio_settings(mut self, settings: AudioSettings) -> Self {
self.audio_settings = Some(settings);
self
}
/// Set output directory.
pub fn with_output_dir(mut self, dir: &Path) -> Self {
self.output_dir = Some(dir.to_path_buf());
self
}
/// Build the OBS context.
pub fn build(self) -> Result<ObsContext> {
let video_settings = self.video_settings.unwrap_or_default();
let audio_settings = self.audio_settings.unwrap_or_default();
let output_dir = self
.output_dir
.unwrap_or_else(|| std::path::PathBuf::from("./recordings"));
// Ensure output directory exists
if !output_dir.exists() {
std::fs::create_dir_all(&output_dir)
.map_err(|e| RecordingError::OutputDirError(e.to_string()))?;
}
let video_info = ObsVideoInfo::from_settings(&video_settings);
let context = ObsContext {
video_info,
audio_settings,
output_dir,
initialized: false,
recording: false,
current_output: None,
};
// Note: Actual libobs initialization would happen here
// For now, we create a stub that can be extended with actual libobs bindings
Ok(context)
}
}
impl Default for ObsContextBuilder {
fn default() -> Self {
Self::new()
}
}
/// OBS context wrapper.
///
/// This manages the OBS library lifecycle and provides access to
/// recording functionality.
pub struct ObsContext {
/// Video configuration.
video_info: ObsVideoInfo,
/// Audio configuration.
audio_settings: AudioSettings,
/// Output directory for recordings.
output_dir: std::path::PathBuf,
/// Whether OBS has been initialized.
initialized: bool,
/// Whether currently recording.
recording: bool,
/// Current output path (if recording).
current_output: Option<std::path::PathBuf>,
}
impl ObsContext {
/// Check if OBS is initialized.
pub fn is_initialized(&self) -> bool {
self.initialized
}
/// Check if currently recording.
pub fn is_recording(&self) -> bool {
self.recording
}
/// Get the video info.
pub fn video_info(&self) -> &ObsVideoInfo {
&self.video_info
}
/// Start recording to the specified output path.
pub fn start_recording(&mut self, output_path: &Path) -> Result<()> {
if self.recording {
return Err(RecordingError::AlreadyRecording.into());
}
if !self.initialized {
// Initialize on first use
self.initialize()?;
}
info!("Starting OBS recording to: {:?}", output_path);
// Note: Actual libobs recording start would happen here
// This is a stub implementation
self.current_output = Some(output_path.to_path_buf());
self.recording = true;
debug!("OBS recording started");
Ok(())
}
/// Stop recording.
pub fn stop_recording(&mut self) -> Result<()> {
if !self.recording {
return Err(RecordingError::NotRecording.into());
}
info!("Stopping OBS recording");
// Note: Actual libobs recording stop would happen here
// This is a stub implementation
self.recording = false;
self.current_output = None;
debug!("OBS recording stopped");
Ok(())
}
/// Initialize OBS.
fn initialize(&mut self) -> Result<()> {
if self.initialized {
return Ok(());
}
info!("Initializing OBS context");
// Note: Actual libobs initialization would happen here
// This would involve:
// 1. obs_startup()
// 2. obs_reset_video()
// 3. obs_reset_audio()
// 4. Loading modules (obs-ffmpeg, etc.)
// 5. Creating scene and source
// For now, we simulate initialization
debug!(
"OBS video config: {}x{} @ {}fps",
self.video_info.output_width,
self.video_info.output_height,
self.video_info.fps_num / self.video_info.fps_den
);
if self.audio_settings.enabled {
debug!(
"OBS audio config: {} channels @ {}Hz",
self.audio_settings.channels, self.audio_settings.sample_rate
);
}
self.initialized = true;
info!("OBS context initialized successfully");
Ok(())
}
/// Shutdown OBS.
pub fn shutdown(&mut self) -> Result<()> {
if self.recording {
self.stop_recording()?;
}
if self.initialized {
info!("Shutting down OBS context");
// Note: Actual libobs shutdown would happen here
// obs_shutdown()
self.initialized = false;
}
Ok(())
}
}
impl Drop for ObsContext {
fn drop(&mut self) {
if let Err(e) = self.shutdown() {
warn!("Error shutting down OBS context: {}", e);
}
}
}
// Note: When actual libobs bindings are available, we would add
// FFI bindings here. For now, this provides the interface that
// will be implemented with real libobs calls.
/// Stub module for libobs FFI bindings.
///
/// When actual bindings are available, this would contain:
/// - obs_startup
/// - obs_shutdown
/// - obs_reset_video
/// - obs_reset_audio
/// - obs_scene_create
/// - obs_source_create
/// - obs_output_create
/// - obs_encoder_create
/// etc.
pub mod ffi {
//! libobs FFI bindings (stub).
//!
//! This module will contain the actual FFI bindings to libobs.
//! Currently using stubs until libobs-rs or similar bindings are integrated.
/// Placeholder for obs_video_info struct.
#[repr(C)]
pub struct ObsVideoInfo {
pub adapter: i32,
pub output_width: u32,
pub output_height: u32,
pub fps_num: u32,
pub fps_den: u32,
pub base_width: u32,
pub base_height: u32,
pub output_format: u32,
}
/// Placeholder for obs_audio_info struct.
#[repr(C)]
pub struct ObsAudioInfo {
pub samples_per_sec: u32,
pub speakers: u32,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_obs_context_builder() {
let context = ObsContextBuilder::new()
.with_video_settings(VideoSettings::default())
.with_audio_settings(AudioSettings::default())
.build()
.unwrap();
assert!(!context.is_initialized());
assert!(!context.is_recording());
}
#[test]
fn test_video_info_from_settings() {
let settings = VideoSettings::default();
let info = ObsVideoInfo::from_settings(&settings);
assert_eq!(info.output_width, 1920);
assert_eq!(info.output_height, 1080);
assert_eq!(info.fps_num, 60);
}
}

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());
}
}

View File

@@ -0,0 +1,293 @@
//! Daemon state machine implementation.
use std::sync::Arc;
use parking_lot::RwLock;
use tracing::{info, trace, warn};
use super::DaemonStatus;
use crate::lqp::{GameEvent, GameflowPhase};
/// Internal daemon state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DaemonState {
/// Idle - League Client not detected.
Idle,
/// Monitoring - League Client running, waiting for game.
Monitoring,
/// Recording - Active recording in progress.
Recording,
/// Error - Recoverable error state.
Error,
/// ShuttingDown - Daemon is shutting down.
ShuttingDown,
}
impl From<DaemonState> for DaemonStatus {
fn from(state: DaemonState) -> Self {
match state {
DaemonState::Idle => DaemonStatus::Idle,
DaemonState::Monitoring => DaemonStatus::Monitoring,
DaemonState::Recording => DaemonStatus::Recording,
DaemonState::Error => DaemonStatus::Error,
DaemonState::ShuttingDown => DaemonStatus::ShuttingDown,
}
}
}
/// State transition event.
#[derive(Debug, Clone)]
pub enum StateTransition {
/// League Client started.
ClientStarted,
/// League Client stopped.
ClientStopped,
/// Game started.
GameStarted {
game_id: u64,
champion: Option<String>,
},
/// Game ended.
GameEnded,
/// Error occurred.
Error(String),
/// Error recovered.
Recovered,
/// Shutdown requested.
Shutdown,
/// Gameflow phase changed.
PhaseChanged(GameflowPhase),
}
/// State machine for daemon state management.
pub struct DaemonStateMachine {
/// Current state.
state: Arc<RwLock<DaemonState>>,
/// Last error message.
last_error: Arc<RwLock<Option<String>>>,
/// Current game ID (if recording).
current_game_id: Arc<RwLock<Option<u64>>>,
/// Current champion (if recording).
current_champion: Arc<RwLock<Option<String>>>,
}
impl DaemonStateMachine {
/// Create a new state machine.
pub fn new() -> Self {
Self {
state: Arc::new(RwLock::new(DaemonState::Idle)),
last_error: Arc::new(RwLock::new(None)),
current_game_id: Arc::new(RwLock::new(None)),
current_champion: Arc::new(RwLock::new(None)),
}
}
/// Get the current state.
pub fn current_state(&self) -> DaemonState {
*self.state.read()
}
/// Get the current status (for external reporting).
pub fn status(&self) -> DaemonStatus {
self.current_state().into()
}
/// Get the current game ID.
pub fn current_game_id(&self) -> Option<u64> {
*self.current_game_id.read()
}
/// Get the current champion.
pub fn current_champion(&self) -> Option<String> {
self.current_champion.read().clone()
}
/// Get the last error.
pub fn last_error(&self) -> Option<String> {
self.last_error.read().clone()
}
/// Check if currently recording.
pub fn is_recording(&self) -> bool {
*self.state.read() == DaemonState::Recording
}
/// Check if monitoring for games.
pub fn is_monitoring(&self) -> bool {
matches!(
*self.state.read(),
DaemonState::Monitoring | DaemonState::Recording
)
}
/// Process a state transition.
///
/// Returns the new state if the transition was valid.
pub fn transition(&self, transition: StateTransition) -> Option<DaemonState> {
let current = self.current_state();
let new_state = self.apply_transition(current, transition.clone())?;
if new_state != current {
info!(
"State transition: {:?} -> {:?} (via {:?})",
current, new_state, transition
);
*self.state.write() = new_state;
// Update related state
match &transition {
StateTransition::GameStarted { game_id, champion } => {
*self.current_game_id.write() = Some(*game_id);
*self.current_champion.write() = champion.clone();
}
StateTransition::GameEnded => {
*self.current_game_id.write() = None;
*self.current_champion.write() = None;
}
StateTransition::Error(msg) => {
*self.last_error.write() = Some(msg.clone());
}
StateTransition::Recovered => {
*self.last_error.write() = None;
}
_ => {}
}
}
Some(new_state)
}
/// Apply a transition to determine the new state.
fn apply_transition(
&self,
current: DaemonState,
transition: StateTransition,
) -> Option<DaemonState> {
match (current, &transition) {
// From Idle
(DaemonState::Idle, StateTransition::ClientStarted) => Some(DaemonState::Monitoring),
(DaemonState::Idle, StateTransition::Shutdown) => Some(DaemonState::ShuttingDown),
// From Monitoring
(DaemonState::Monitoring, StateTransition::ClientStopped) => Some(DaemonState::Idle),
(DaemonState::Monitoring, StateTransition::GameStarted { .. }) => {
Some(DaemonState::Recording)
}
(DaemonState::Monitoring, StateTransition::Error(_)) => Some(DaemonState::Error),
(DaemonState::Monitoring, StateTransition::Shutdown) => Some(DaemonState::ShuttingDown),
// From Recording
(DaemonState::Recording, StateTransition::GameEnded) => Some(DaemonState::Monitoring),
(DaemonState::Recording, StateTransition::ClientStopped) => Some(DaemonState::Idle),
(DaemonState::Recording, StateTransition::Error(_)) => Some(DaemonState::Error),
(DaemonState::Recording, StateTransition::Shutdown) => Some(DaemonState::ShuttingDown),
// From Error
(DaemonState::Error, StateTransition::Recovered) => Some(DaemonState::Idle),
(DaemonState::Error, StateTransition::ClientStopped) => Some(DaemonState::Idle),
(DaemonState::Error, StateTransition::Shutdown) => Some(DaemonState::ShuttingDown),
// Invalid transitions
_ => {
warn!(
"Invalid state transition: {:?} with {:?}",
current, transition
);
None
}
}
}
/// Process a game event and potentially trigger a transition.
pub fn process_event(&self, event: &GameEvent) -> Option<StateTransition> {
trace!(
"Processing event in state {:?}: {:?}",
self.current_state(),
event
);
match event {
GameEvent::GameStart(info) => Some(StateTransition::GameStarted {
game_id: info.game_id,
champion: info.champion.clone(),
}),
GameEvent::GameEnd(_) => Some(StateTransition::GameEnded),
_ => None,
}
}
/// Force a state (for testing or recovery).
pub fn force_state(&self, state: DaemonState) {
*self.state.write() = state;
}
}
impl Default for DaemonStateMachine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_initial_state() {
let machine = DaemonStateMachine::new();
assert_eq!(machine.current_state(), DaemonState::Idle);
}
#[test]
fn test_client_start_transition() {
let machine = DaemonStateMachine::new();
let new_state = machine.transition(StateTransition::ClientStarted);
assert_eq!(new_state, Some(DaemonState::Monitoring));
assert_eq!(machine.current_state(), DaemonState::Monitoring);
}
#[test]
fn test_game_start_transition() {
let machine = DaemonStateMachine::new();
machine.transition(StateTransition::ClientStarted);
let new_state = machine.transition(StateTransition::GameStarted {
game_id: 12345,
champion: Some("Ahri".to_string()),
});
assert_eq!(new_state, Some(DaemonState::Recording));
assert_eq!(machine.current_game_id(), Some(12345));
assert_eq!(machine.current_champion(), Some("Ahri".to_string()));
}
#[test]
fn test_invalid_transition() {
let machine = DaemonStateMachine::new();
// Can't start recording from Idle
let result = machine.transition(StateTransition::GameStarted {
game_id: 12345,
champion: None,
});
assert_eq!(result, None);
assert_eq!(machine.current_state(), DaemonState::Idle);
}
#[test]
fn test_error_recovery() {
let machine = DaemonStateMachine::new();
machine.transition(StateTransition::ClientStarted);
machine.transition(StateTransition::Error("Test error".to_string()));
assert_eq!(machine.current_state(), DaemonState::Error);
assert_eq!(machine.last_error(), Some("Test error".to_string()));
machine.transition(StateTransition::Recovered);
assert_eq!(machine.current_state(), DaemonState::Idle);
assert_eq!(machine.last_error(), None);
}
}

View File

@@ -0,0 +1,35 @@
//! Daemon state machine module.
mod machine;
pub use machine::{DaemonState, DaemonStateMachine, StateTransition};
use serde::{Deserialize, Serialize};
/// Daemon status for external reporting.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DaemonStatus {
/// Daemon is idle, waiting for League Client.
Idle,
/// Daemon is monitoring for game start.
Monitoring,
/// Daemon is actively recording.
Recording,
/// Daemon encountered an error.
Error,
/// Daemon is shutting down.
ShuttingDown,
}
impl std::fmt::Display for DaemonStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DaemonStatus::Idle => write!(f, "Idle"),
DaemonStatus::Monitoring => write!(f, "Monitoring"),
DaemonStatus::Recording => write!(f, "Recording"),
DaemonStatus::Error => write!(f, "Error"),
DaemonStatus::ShuttingDown => write!(f, "Shutting Down"),
}
}
}

View File

@@ -0,0 +1,222 @@
//! Event mapper for mapping game events to video timestamps.
use chrono::{DateTime, Duration, Utc};
use tracing::debug;
use crate::lqp::GameEvent;
/// Event mapper that tracks recording start time and maps events to video timestamps.
pub struct EventMapper {
/// Recording start time.
start_time: Option<DateTime<Utc>>,
/// Game start time (from game event).
game_start_time: Option<DateTime<Utc>>,
}
impl EventMapper {
/// Create a new event mapper.
pub fn new() -> Self {
Self {
start_time: None,
game_start_time: None,
}
}
/// Start the mapper (recording started).
pub fn start(&mut self) {
self.start_time = Some(Utc::now());
debug!("Event mapper started at {:?}", self.start_time);
}
/// Stop the mapper (recording stopped).
pub fn stop(&mut self) {
self.start_time = None;
self.game_start_time = None;
debug!("Event mapper stopped");
}
/// Check if the mapper is active.
pub fn is_active(&self) -> bool {
self.start_time.is_some()
}
/// Map a game event to video and game timestamps.
pub fn map_event(&self, event: &GameEvent) -> Option<(Duration, Option<Duration>)> {
let start_time = self.start_time?;
let now = Utc::now();
// Calculate video timestamp (time since recording started)
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);
// Update game start time if this is a game start event
// (handled separately in handle_event)
Some((video_timestamp, game_timestamp))
}
/// Handle a game event and return mapped timestamps.
pub fn handle_event(&mut self, event: &GameEvent) -> Option<(Duration, Option<Duration>)> {
if !self.is_active() {
return None;
}
// Track game start time
if let GameEvent::GameStart(_) = event {
self.game_start_time = Some(Utc::now());
debug!("Game start time recorded: {:?}", self.game_start_time);
}
self.map_event(event)
}
/// Get the current video timestamp.
pub fn current_video_timestamp(&self) -> Option<Duration> {
self.start_time.map(|start| Utc::now() - start)
}
/// Get the current game timestamp.
pub fn current_game_timestamp(&self) -> Option<Duration> {
self.game_start_time.map(|start| Utc::now() - start)
}
/// Get the recording duration so far.
pub fn recording_duration(&self) -> Option<Duration> {
self.current_video_timestamp()
}
/// Reset the mapper.
pub fn reset(&mut self) {
self.start_time = None;
self.game_start_time = None;
debug!("Event mapper reset");
}
}
impl Default for EventMapper {
fn default() -> Self {
Self::new()
}
}
/// Event synchronizer for keeping video and game time in sync.
///
/// This handles cases where the game time might drift from real time,
/// such as when the game pauses or lags.
pub struct EventSynchronizer {
/// Known sync points (video timestamp, game timestamp).
sync_points: Vec<(Duration, Duration)>,
/// Maximum allowed drift in seconds.
max_drift_secs: f64,
}
impl EventSynchronizer {
/// Create a new event synchronizer.
pub fn new() -> Self {
Self {
sync_points: Vec::new(),
max_drift_secs: 5.0,
}
}
/// Add a sync point.
pub fn add_sync_point(&mut self, video_ts: Duration, game_ts: Duration) {
self.sync_points.push((video_ts, game_ts));
// Keep only recent sync points
if self.sync_points.len() > 100 {
self.sync_points.remove(0);
}
}
/// Calculate the drift between video and game time.
pub fn calculate_drift(&self) -> Option<Duration> {
if self.sync_points.len() < 2 {
return None;
}
let first = self.sync_points.first()?;
let last = self.sync_points.last()?;
let video_diff = last.0 - first.0;
let game_diff = last.1 - first.1;
Some(video_diff - game_diff)
}
/// Check if the drift is within acceptable bounds.
pub fn is_drift_acceptable(&self) -> bool {
self.calculate_drift()
.map(|drift| (drift.num_seconds().abs() as f64) < self.max_drift_secs)
.unwrap_or(true)
}
/// Adjust a game timestamp based on known drift.
pub fn adjust_game_timestamp(&self, game_ts: Duration) -> Duration {
if let Some(drift) = self.calculate_drift() {
game_ts + drift
} else {
game_ts
}
}
/// Reset the synchronizer.
pub fn reset(&mut self) {
self.sync_points.clear();
}
}
impl Default for EventSynchronizer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread::sleep;
use std::time::Duration as StdDuration;
#[test]
fn test_event_mapper_start_stop() {
let mut mapper = EventMapper::new();
assert!(!mapper.is_active());
mapper.start();
assert!(mapper.is_active());
mapper.stop();
assert!(!mapper.is_active());
}
#[test]
fn test_event_mapper_timestamps() {
let mut mapper = EventMapper::new();
mapper.start();
sleep(StdDuration::from_millis(100));
let ts = mapper.current_video_timestamp().unwrap();
assert!(ts.num_milliseconds() >= 100);
}
#[test]
fn test_event_synchronizer() {
let mut sync = EventSynchronizer::new();
sync.add_sync_point(Duration::seconds(0), Duration::seconds(0));
sync.add_sync_point(Duration::seconds(10), Duration::seconds(10));
assert!(sync.is_drift_acceptable());
// Add a drift
sync.add_sync_point(Duration::seconds(20), Duration::seconds(18));
let drift = sync.calculate_drift().unwrap();
assert_eq!(drift.num_seconds(), 2);
}
}

View File

@@ -0,0 +1,193 @@
//! Timeline module for storing game events and mapping them to video timestamps.
mod mapper;
mod store;
pub use mapper::EventMapper;
pub use store::{RecordingMetadata, TimelineStore, TimestampedEvent};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::lqp::GameEvent;
/// A timeline of events for a recording.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Timeline {
/// Recording ID.
pub recording_id: Uuid,
/// Recording start time.
pub start_time: DateTime<Utc>,
/// Recording end time.
pub end_time: Option<DateTime<Utc>>,
/// Total duration in seconds.
pub duration_secs: i64,
/// Events in the timeline.
pub events: Vec<TimestampedEvent>,
}
impl Timeline {
/// Get the duration as chrono Duration.
pub fn duration(&self) -> Duration {
Duration::seconds(self.duration_secs)
}
}
impl Timeline {
/// Create a new timeline for a recording.
pub fn new(recording_id: Uuid) -> Self {
Self {
recording_id,
start_time: Utc::now(),
end_time: None,
duration_secs: 0,
events: Vec::new(),
}
}
/// Add an event to the timeline.
pub fn add_event(
&mut self,
event: GameEvent,
video_timestamp: Duration,
game_timestamp: Option<Duration>,
) {
let timestamped = TimestampedEvent {
video_timestamp,
game_timestamp,
timestamp: Utc::now(),
event_type: event_type_name(&event),
description: event.description(),
event,
};
self.events.push(timestamped);
}
/// Finalize the timeline.
pub fn finalize(&mut self) {
self.end_time = Some(Utc::now());
self.duration_secs = (self.end_time.unwrap() - self.start_time).num_seconds();
}
/// Get events within a time range.
pub fn events_in_range(&self, start: Duration, end: Duration) -> Vec<&TimestampedEvent> {
self.events
.iter()
.filter(|e| e.video_timestamp >= start && e.video_timestamp <= end)
.collect()
}
/// Get events of a specific type.
pub fn events_of_type(&self, event_type: &str) -> Vec<&TimestampedEvent> {
self.events
.iter()
.filter(|e| e.event_type == event_type)
.collect()
}
/// Get the number of events.
pub fn event_count(&self) -> usize {
self.events.len()
}
/// Export timeline to JSON.
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
/// Export timeline to CSV.
pub fn to_csv(&self) -> String {
let mut csv =
String::from("video_timestamp,game_timestamp,event_type,description,timestamp\n");
for event in &self.events {
let video_ts = format_timestamp(event.video_timestamp);
let game_ts = event
.game_timestamp
.map(format_timestamp)
.unwrap_or_default();
csv.push_str(&format!(
"{},{},{},{},{}\n",
video_ts,
game_ts,
event.event_type,
event.description,
event.timestamp.to_rfc3339(),
));
}
csv
}
}
/// Format a duration as HH:MM:SS.mmm
fn format_timestamp(duration: Duration) -> String {
let total_ms = duration.num_milliseconds();
let hours = total_ms / 3_600_000;
let minutes = (total_ms % 3_600_000) / 60_000;
let seconds = (total_ms % 60_000) / 1000;
let millis = total_ms % 1000;
format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, millis)
}
/// Get the event type name.
fn event_type_name(event: &GameEvent) -> String {
match event {
GameEvent::MatchFound(_) => "match_found",
GameEvent::GameStart(_) => "game_start",
GameEvent::Kill(_) => "kill",
GameEvent::Death(_) => "death",
GameEvent::Objective(_) => "objective",
GameEvent::GameEnd(_) => "game_end",
GameEvent::Unknown => "unknown",
}
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lqp::{KillEvent, ObjectiveEvent, ObjectiveType};
#[test]
fn test_timeline_creation() {
let id = Uuid::new_v4();
let timeline = Timeline::new(id);
assert_eq!(timeline.recording_id, id);
assert!(timeline.events.is_empty());
}
#[test]
fn test_add_event() {
let id = Uuid::new_v4();
let mut timeline = Timeline::new(id);
let event = GameEvent::Kill(KillEvent {
killer: "Player1".to_string(),
killer_champion: Some("Ahri".to_string()),
victim: "Player2".to_string(),
victim_champion: Some("Lux".to_string()),
solo_kill: true,
assists: 0,
position: None,
game_time: Some(120.0),
timestamp: Utc::now(),
});
timeline.add_event(event, Duration::seconds(5), Some(Duration::seconds(120)));
assert_eq!(timeline.event_count(), 1);
}
#[test]
fn test_format_timestamp() {
let duration = Duration::milliseconds(3723456); // 1:02:03.456
let formatted = format_timestamp(duration);
assert_eq!(formatted, "01:02:03.456");
}
}

View File

@@ -0,0 +1,311 @@
//! Timeline storage backend.
use std::collections::HashMap;
use std::path::PathBuf;
use chrono::{DateTime, Duration, Utc};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{Result, TimelineError};
use crate::lqp::GameEvent;
use crate::recording::RecordingResult;
/// A timestamped event in the timeline.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimestampedEvent {
/// Video timestamp (offset from recording start).
// #[serde(with = "chrono::serde::seconds")]
pub video_timestamp: Duration,
/// Game timestamp (in-game time).
// #[serde(with = "chrono::serde::seconds_option")]
pub game_timestamp: Option<Duration>,
/// Real-world timestamp.
pub timestamp: DateTime<Utc>,
/// Event type name.
pub event_type: String,
/// Human-readable description.
pub description: String,
/// The actual event data.
pub event: GameEvent,
}
/// Metadata for a recording.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordingMetadata {
/// Unique recording ID.
pub id: Uuid,
/// Game ID if available.
pub game_id: Option<u64>,
/// Champion played.
pub champion: Option<String>,
/// Recording start time.
pub start_time: DateTime<Utc>,
/// Recording end time.
pub end_time: Option<DateTime<Utc>>,
/// Recording duration.
// #[serde(with = "chrono::serde::seconds")]
pub duration: Duration,
/// Output file path.
pub file_path: PathBuf,
/// File size in bytes.
pub file_size: Option<u64>,
/// Number of events.
pub event_count: usize,
/// Whether the timeline has been finalized.
pub finalized: bool,
}
impl RecordingMetadata {
/// Create metadata from a recording result.
pub fn from_result(result: &RecordingResult) -> Self {
Self {
id: Uuid::new_v4(),
game_id: result.game_id,
champion: result.champion.clone(),
start_time: result.start_time,
end_time: Some(result.end_time),
duration: result.duration,
file_path: result.path.clone(),
file_size: result.file_size(),
event_count: 0,
finalized: false,
}
}
}
/// In-memory timeline storage.
pub struct TimelineStore {
/// Recording metadata by ID.
recordings: RwLock<HashMap<Uuid, RecordingMetadata>>,
/// Timelines by recording ID.
timelines: RwLock<HashMap<Uuid, Vec<TimestampedEvent>>>,
/// Storage directory for persistence.
storage_dir: PathBuf,
}
impl TimelineStore {
/// Create a new timeline store.
pub fn new() -> Self {
let storage_dir = crate::config::get_default_output_dir()
.unwrap_or_else(|| PathBuf::from("./recordings"))
.join("timelines");
Self {
recordings: RwLock::new(HashMap::new()),
timelines: RwLock::new(HashMap::new()),
storage_dir,
}
}
/// Create a timeline store with a specific storage directory.
pub fn with_dir(storage_dir: PathBuf) -> Self {
Self {
recordings: RwLock::new(HashMap::new()),
timelines: RwLock::new(HashMap::new()),
storage_dir,
}
}
/// Add a new recording.
pub fn add_recording(&self, result: RecordingResult) -> Result<Uuid> {
let id = Uuid::new_v4();
let metadata = RecordingMetadata {
id,
game_id: result.game_id,
champion: result.champion.clone(),
start_time: result.start_time,
end_time: Some(result.end_time),
duration: result.duration,
file_path: result.path.clone(),
file_size: result.file_size(),
event_count: 0,
finalized: true,
};
self.recordings.write().insert(id, metadata);
self.timelines.write().insert(id, Vec::new());
// Persist to disk
self.persist_recording(id)?;
Ok(id)
}
/// Add an event to a recording's timeline.
pub fn add_event(&self, recording_id: Uuid, event: TimestampedEvent) -> Result<()> {
let mut timelines = self.timelines.write();
let timeline = timelines
.get_mut(&recording_id)
.ok_or(TimelineError::RecordingNotFound(recording_id))?;
timeline.push(event);
// Update event count in metadata
drop(timelines);
let mut recordings = self.recordings.write();
if let Some(metadata) = recordings.get_mut(&recording_id) {
metadata.event_count += 1;
}
Ok(())
}
/// Get all recordings.
pub fn get_all_recordings(&self) -> Result<Vec<RecordingMetadata>> {
let recordings = self.recordings.read();
Ok(recordings.values().cloned().collect())
}
/// Get a specific recording.
pub fn get_recording(&self, id: Uuid) -> Result<RecordingMetadata> {
self.recordings
.read()
.get(&id)
.cloned()
.ok_or_else(|| TimelineError::RecordingNotFound(id).into())
}
/// Get the timeline for a recording.
pub fn get_timeline(&self, recording_id: Uuid) -> Result<super::Timeline> {
let recordings = self.recordings.read();
let metadata = recordings
.get(&recording_id)
.ok_or(TimelineError::RecordingNotFound(recording_id))?;
let timelines = self.timelines.read();
let events = timelines.get(&recording_id).cloned().unwrap_or_default();
Ok(super::Timeline {
recording_id,
start_time: metadata.start_time,
end_time: metadata.end_time,
duration_secs: metadata.duration.num_seconds(),
events,
})
}
/// Delete a recording and its timeline.
pub fn delete_recording(&self, id: Uuid) -> Result<()> {
// Remove from memory
self.recordings.write().remove(&id);
self.timelines.write().remove(&id);
// Remove from disk
let timeline_file = self.storage_dir.join(format!("{}.json", id));
if timeline_file.exists() {
std::fs::remove_file(&timeline_file)?;
}
Ok(())
}
/// Persist a recording to disk.
fn persist_recording(&self, id: Uuid) -> Result<()> {
// Ensure storage directory exists
if !self.storage_dir.exists() {
std::fs::create_dir_all(&self.storage_dir)?;
}
let metadata = self.recordings.read().get(&id).cloned();
let events = self.timelines.read().get(&id).cloned();
if let (Some(metadata), Some(events)) = (metadata, events) {
let timeline = super::Timeline {
recording_id: id,
start_time: metadata.start_time,
end_time: metadata.end_time,
duration_secs: metadata.duration.num_seconds(),
events,
};
let file_path = self.storage_dir.join(format!("{}.json", id));
let json = serde_json::to_string_pretty(&timeline)?;
std::fs::write(&file_path, json)?;
}
Ok(())
}
/// Load all recordings from disk.
pub fn load_from_disk(&self) -> Result<()> {
if !self.storage_dir.exists() {
return Ok(());
}
for entry in std::fs::read_dir(&self.storage_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map(|e| e == "json").unwrap_or(false) {
if let Ok(contents) = std::fs::read_to_string(&path) {
if let Ok(timeline) = serde_json::from_str::<super::Timeline>(&contents) {
let metadata = RecordingMetadata {
id: timeline.recording_id,
game_id: None,
champion: None,
start_time: timeline.start_time,
end_time: timeline.end_time,
duration: timeline.duration(),
file_path: PathBuf::new(),
file_size: None,
event_count: timeline.events.len(),
finalized: true,
};
self.recordings
.write()
.insert(timeline.recording_id, metadata);
self.timelines
.write()
.insert(timeline.recording_id, timeline.events);
}
}
}
}
Ok(())
}
}
impl Default for TimelineStore {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_timeline_store_creation() {
let store = TimelineStore::new();
let recordings = store.get_all_recordings().unwrap();
assert!(recordings.is_empty());
}
#[test]
fn test_add_recording() {
let store = TimelineStore::new();
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(60),
duration: Duration::seconds(60),
};
let id = store.add_recording(result).unwrap();
let recordings = store.get_all_recordings().unwrap();
assert_eq!(recordings.len(), 1);
let metadata = store.get_recording(id).unwrap();
assert_eq!(metadata.champion, Some("Ahri".to_string()));
}
}