record-daemon: initial commit
This commit is contained in:
446
plans/record-daemon-architecture.md
Normal file
446
plans/record-daemon-architecture.md
Normal 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
1
record-daemon/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
2414
record-daemon/Cargo.lock
generated
Normal file
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
80
record-daemon/Cargo.toml
Normal 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
627
record-daemon/README.md
Normal 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
|
||||
29
record-daemon/src/config/mod.rs
Normal file
29
record-daemon/src/config/mod.rs
Normal 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"))
|
||||
}
|
||||
176
record-daemon/src/config/persistence.rs
Normal file
176
record-daemon/src/config/persistence.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
303
record-daemon/src/config/presets.rs
Normal file
303
record-daemon/src/config/presets.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
179
record-daemon/src/config/settings.rs
Normal file
179
record-daemon/src/config/settings.rs
Normal 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
171
record-daemon/src/error.rs
Normal 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>;
|
||||
305
record-daemon/src/ipc/handlers.rs
Normal file
305
record-daemon/src/ipc/handlers.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
29
record-daemon/src/ipc/mod.rs
Normal file
29
record-daemon/src/ipc/mod.rs
Normal 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")
|
||||
}
|
||||
277
record-daemon/src/ipc/protocol.rs
Normal file
277
record-daemon/src/ipc/protocol.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
464
record-daemon/src/ipc/server.rs
Normal file
464
record-daemon/src/ipc/server.rs
Normal 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
27
record-daemon/src/lib.rs
Normal 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");
|
||||
340
record-daemon/src/lqp/auth.rs
Normal file
340
record-daemon/src/lqp/auth.rs
Normal 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 "));
|
||||
}
|
||||
}
|
||||
423
record-daemon/src/lqp/client.rs
Normal file
423
record-daemon/src/lqp/client.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
346
record-daemon/src/lqp/events.rs
Normal file
346
record-daemon/src/lqp/events.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
15
record-daemon/src/lqp/mod.rs
Normal file
15
record-daemon/src/lqp/mod.rs
Normal 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
355
record-daemon/src/main.rs
Normal 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
|
||||
}
|
||||
293
record-daemon/src/recording/capture.rs
Normal file
293
record-daemon/src/recording/capture.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
303
record-daemon/src/recording/encoder.rs
Normal file
303
record-daemon/src/recording/encoder.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
241
record-daemon/src/recording/mod.rs
Normal file
241
record-daemon/src/recording/mod.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
360
record-daemon/src/recording/obs_context.rs
Normal file
360
record-daemon/src/recording/obs_context.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
228
record-daemon/src/recording/output.rs
Normal file
228
record-daemon/src/recording/output.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
293
record-daemon/src/state/machine.rs
Normal file
293
record-daemon/src/state/machine.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
35
record-daemon/src/state/mod.rs
Normal file
35
record-daemon/src/state/mod.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
222
record-daemon/src/timeline/mapper.rs
Normal file
222
record-daemon/src/timeline/mapper.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
193
record-daemon/src/timeline/mod.rs
Normal file
193
record-daemon/src/timeline/mod.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
311
record-daemon/src/timeline/store.rs
Normal file
311
record-daemon/src/timeline/store.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user