record-daemon: initial commit

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

View File

@@ -0,0 +1,446 @@
# Record Daemon Architecture
## Overview
The record-daemon is a high-performance Rust daemon that 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.
## High-Level Architecture
```mermaid
flowchart TB
subgraph Daemon[Record Daemon]
direction TB
Main[Main Event Loop]
State[State Machine]
subgraph Core[Core Modules]
Config[Configuration Manager]
LQP[LQP Client]
OBS[libobs Recording Engine]
IPC[IPC Server]
Timeline[Event Timeline Store]
end
end
subgraph External[External Systems]
LC[League Client]
Game[League of Legends Game]
Tauri[Tauri App]
FS[File System]
end
LQP -->|WebSocket| LC
OBS -->|Game Capture| Game
IPC -->|Unix Socket| Tauri
Config -->|JSON/TOML| FS
Timeline -->|SQLite/JSON| FS
OBS -->|Video Files| FS
Main --> State
State --> Config
State --> LQP
State --> OBS
State --> IPC
State --> Timeline
```
## Module Structure
```
record-daemon/
├── Cargo.toml
├── src/
│ ├── main.rs # Entry point, daemon setup
│ ├── lib.rs # Library exports
│ ├── config/
│ │ ├── mod.rs # Config module exports
│ │ ├── settings.rs # User-configurable settings
│ │ ├── presets.rs # Encoding presets (quality/performance)
│ │ └── persistence.rs # Config file I/O
│ ├── lqp/
│ │ ├── mod.rs # LQP module exports
│ │ ├── client.rs # LQP WebSocket client
│ │ ├── events.rs # Game event types and parsing
│ │ └── auth.rs # LQP authentication (port/password from lockfile)
│ ├── recording/
│ │ ├── mod.rs # Recording module exports
│ │ ├── obs_context.rs # libobs initialization and context
│ │ ├── capture.rs # Game capture source setup
│ │ ├── encoder.rs # Video encoder configuration
│ │ └── output.rs # File output configuration
│ ├── ipc/
│ │ ├── mod.rs # IPC module exports
│ │ ├── server.rs # Unix socket server
│ │ ├── protocol.rs # Request/response protocol
│ │ └── handlers.rs # Command handlers
│ ├── timeline/
│ │ ├── mod.rs # Timeline module exports
│ │ ├── store.rs # Event storage backend
│ │ └── mapper.rs # Event-to-video timestamp mapping
│ ├── state/
│ │ ├── mod.rs # State module exports
│ │ ├── machine.rs # Daemon state machine
│ │ └── transitions.rs # State transition logic
│ └── error.rs # Error types and handling
└── tests/
└── ...
```
## State Machine
The daemon operates as a finite state machine with the following states:
```mermaid
stateDiagram-v2
[*] --> Idle: Daemon Start
Idle --> Monitoring: League Client Detected
Monitoring --> Idle: League Client Closed
Monitoring --> Recording: Match Started
Recording --> Monitoring: Match Ended
Recording --> Recording: Event Received
Idle --> Error: Fatal Error
Monitoring --> Error: Fatal Error
Recording --> Error: Fatal Error
Error --> Idle: Recovery
```
### State Descriptions
| State | Description |
|-------|-------------|
| **Idle** | Daemon is running but League Client is not detected |
| **Monitoring** | League Client is running, waiting for match to start |
| **Recording** | Active recording in progress, capturing events |
| **Error** | Recoverable error state, attempting recovery |
## Data Flow
### Match Recording Flow
```mermaid
sequenceDiagram
participant D as Daemon
participant LC as League Client
participant OBS as libobs
participant TL as Timeline
participant FS as FileSystem
Note over D: State: Monitoring
LC->>D: MatchFound event via LQP
D->>OBS: Initialize recording context
D->>FS: Create output file
Note over D: State: Recording
LC->>D: GameStart event
D->>OBS: Start recording
D->>TL: Record start timestamp
loop Game Events
LC->>D: Kill/Death/Objective events
D->>TL: Store event with timestamp
end
LC->>D: GameEnd event
D->>OBS: Stop recording
D->>TL: Finalize timeline
D->>FS: Save timeline metadata
Note over D: State: Monitoring
```
### IPC Communication Flow
```mermaid
sequenceDiagram
participant T as Tauri App
participant IPC as IPC Server
participant D as Daemon Core
participant C as Config Manager
T->>IPC: Connect via Unix Socket
T->>IPC: GetSettings request
IPC->>D: Handle request
D->>C: Read current settings
C-->>D: Settings data
D-->>IPC: Settings response
IPC-->>T: JSON response
T->>IPC: UpdateSettings request
IPC->>D: Handle request
D->>C: Validate and save
C-->>D: Success
D-->>IPC: Confirmation
IPC-->>T: Success response
```
## Key Components
### 1. Configuration Module
Handles user-configurable settings with hot-reload support.
```rust
// Key configuration structures
struct Settings {
output_path: PathBuf,
encoder_preset: EncoderPreset,
frame_rate: u32,
quality: QualityLevel,
audio_capture: AudioSettings,
}
enum EncoderPreset {
Nvenc { bitrate: u32, cq_level: u32 },
Amf { bitrate: u32 },
X264 { preset: String, bitrate: u32 },
}
enum QualityLevel {
Low, // 720p30, lower bitrate
Medium, // 1080p30, moderate bitrate
High, // 1080p60, high bitrate
Ultra, // 1440p60, max quality
}
```
### 2. LQP Client
Connects to the League Client via WebSocket using credentials from the lockfile.
```rust
struct LqpClient {
port: u16,
password: String,
websocket: Option<WebSocket>,
}
// Key events to capture
enum GameEvent {
MatchFound(MatchInfo),
GameStart(GameStartInfo),
Kill(KillEvent),
Death(DeathEvent),
Objective(ObjectiveEvent),
GameEnd(GameEndInfo),
}
```
### 3. libobs Recording Engine
Manages OBS context, capture sources, and encoding.
```rust
struct ObsRecordingEngine {
context: ObsContext,
video_encoder: Box<dyn VideoEncoder>,
audio_encoder: Box<dyn AudioEncoder>,
output: Box<dyn Output>,
active: bool,
}
impl ObsRecordingEngine {
fn initialize(config: &Settings) -> Result<Self>;
fn start_recording(&mut self, output_path: &Path) -> Result<()>;
fn stop_recording(&mut self) -> Result<RecordingResult>;
fn get_current_timestamp(&self) -> Duration;
}
```
### 4. IPC Server
Unix socket server for communication with Tauri app.
```rust
struct IpcServer {
socket_path: PathBuf,
listener: UnixListener,
handlers: HashMap<IpcCommand, CommandHandler>,
}
enum IpcCommand {
GetSettings,
UpdateSettings,
GetStatus,
GetRecordings,
GetTimeline { recording_id: Uuid },
StartRecording,
StopRecording,
}
```
### 5. Event Timeline Store
Stores game events mapped to video timestamps.
```rust
struct TimelineStore {
backend: StorageBackend,
}
struct RecordingMetadata {
id: Uuid,
game_id: u64,
start_time: DateTime<Utc>,
end_time: Option<DateTime<Utc>>,
output_file: PathBuf,
events: Vec<TimestampedEvent>,
}
struct TimestampedEvent {
video_timestamp: Duration, // Offset from recording start
game_timestamp: Duration, // Game time
event: GameEvent,
}
```
## Performance Considerations
### Memory Management
- Use `Arc<RwLock<T>>` for shared state between modules
- Pre-allocate buffers for video encoding
- Use `crossbeam` channels for inter-thread communication
- Implement backpressure on event channels
### CPU Efficiency
- Run libobs on dedicated thread
- Use async/await with `tokio` for I/O operations
- Minimize allocations in hot paths
- Use `parking_lot` locks for better performance
### GPU Acceleration
- Prioritize NVENC/AMF hardware encoding
- Fall back to software encoding only if unavailable
- Configure GPU-based video capture
### Disk I/O
- Use buffered writes for video output
- Store timeline data in memory during recording
- Flush to disk only on recording end
- Consider SSD for output directory
## Dependencies
```toml
[dependencies]
# Async runtime
tokio = { version = "1", features = ["full"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
# libobs bindings
libobs-rs = "0.1" # or appropriate version
# WebSocket for LQP
tokio-tungstenite = "0.21"
# HTTP client for LQP REST API
reqwest = { version = "0.11", 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 (optional, for debugging)
clap = { version = "4", features = ["derive"] }
```
## IPC Protocol Specification
### Message Format
All messages are JSON-encoded with the following structure:
```json
{
"type": "request|response|notification",
"id": "uuid-v4",
"command": "CommandName",
"payload": { ... }
}
```
### Commands
| Command | Direction | Payload | Description |
|---------|-----------|---------|-------------|
| `GetSettings` | Request → | `{}` | Get current settings |
| `GetSettings` | ← Response | `Settings` | Current settings object |
| `UpdateSettings` | Request → | `Settings` | Update settings |
| `UpdateSettings` | ← Response | `{ "success": true }` | Confirmation |
| `GetStatus` | Request → | `{}` | Get daemon status |
| `GetStatus` | ← Response | `DaemonStatus` | Current state and info |
| `GetRecordings` | Request → | `{}` | List all recordings |
| `GetRecordings` | ← Response | `[RecordingMetadata]` | Recording list |
| `GetTimeline` | Request → | `{ "recording_id": "uuid" }` | Get specific timeline |
| `GetTimeline` | ← Response | `Timeline` | Event timeline |
| `RecordingStarted` | ← Notification | `RecordingInfo` | Recording started |
| `RecordingStopped` | ← Notification | `RecordingResult` | Recording ended |
| `GameEvent` | ← Notification | `GameEvent` | Live game event |
## Error Handling Strategy
```rust
#[derive(Debug, thiserror::Error)]
pub enum DaemonError {
#[error("LQP connection failed: {0}")]
LqpConnection(#[from] LqpError),
#[error("Recording error: {0}")]
Recording(#[from] RecordingError),
#[error("Configuration error: {0}")]
Config(#[from] ConfigError),
#[error("IPC error: {0}")]
Ipc(#[from] IpcError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
```
## Next Steps
1. Set up `Cargo.toml` with all required dependencies
2. Implement configuration module first (needed by all other modules)
3. Implement LQP client for game detection and event capture
4. Implement libobs recording engine
5. Implement IPC server
6. Implement timeline storage
7. Wire everything together in the main daemon loop
8. Add comprehensive logging and error handling
9. Write tests and documentation

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