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