commit d6c0334369bb59d99ad1252f4b52dd69215c5eeb Author: Valentin Haudiquet Date: Thu Mar 19 17:48:07 2026 +0100 record-daemon: initial commit diff --git a/plans/record-daemon-architecture.md b/plans/record-daemon-architecture.md new file mode 100644 index 0000000..583e8ec --- /dev/null +++ b/plans/record-daemon-architecture.md @@ -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, +} + +// 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, + audio_encoder: Box, + output: Box, + active: bool, +} + +impl ObsRecordingEngine { + fn initialize(config: &Settings) -> Result; + fn start_recording(&mut self, output_path: &Path) -> Result<()>; + fn stop_recording(&mut self) -> Result; + 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, +} + +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, + end_time: Option>, + output_file: PathBuf, + events: Vec, +} + +struct TimestampedEvent { + video_timestamp: Duration, // Offset from recording start + game_timestamp: Duration, // Game time + event: GameEvent, +} +``` + +## Performance Considerations + +### Memory Management +- Use `Arc>` 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 diff --git a/record-daemon/.gitignore b/record-daemon/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/record-daemon/.gitignore @@ -0,0 +1 @@ +/target diff --git a/record-daemon/Cargo.lock b/record-daemon/Cargo.lock new file mode 100644 index 0000000..e5a2dd0 --- /dev/null +++ b/record-daemon/Cargo.lock @@ -0,0 +1,2414 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper", + "rustls", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "record-daemon" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "chrono", + "clap", + "crossbeam", + "directories", + "futures", + "parking_lot", + "regex", + "reqwest", + "serde", + "serde_json", + "signal-hook", + "signal-hook-tokio", + "tempfile", + "thiserror", + "tokio", + "tokio-test", + "tokio-tungstenite", + "tokio-util", + "toml", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body", + "hyper", + "hyper-rustls", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-rustls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signal-hook-tokio" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213241f76fb1e37e27de3b6aa1b068a2c333233b59cca6634f634b80a27ecf1e" +dependencies = [ + "futures-core", + "libc", + "signal-hook", + "tokio", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "url", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/record-daemon/Cargo.toml b/record-daemon/Cargo.toml new file mode 100644 index 0000000..7641aae --- /dev/null +++ b/record-daemon/Cargo.toml @@ -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 diff --git a/record-daemon/README.md b/record-daemon/README.md new file mode 100644 index 0000000..b8c1fc4 --- /dev/null +++ b/record-daemon/README.md @@ -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 to configuration file | +| `-l, --log-level ` | Log level (trace, debug, info, warn, error) | +| `-f, --foreground` | Run in foreground (don't daemonize) | +| `-s, --socket ` | 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 diff --git a/record-daemon/src/config/mod.rs b/record-daemon/src/config/mod.rs new file mode 100644 index 0000000..7c356bf --- /dev/null +++ b/record-daemon/src/config/mod.rs @@ -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 { + 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 { + directories::ProjectDirs::from("com", "leaguerecorder", "record-daemon") + .map(|dirs| dirs.data_dir().join("recordings")) +} diff --git a/record-daemon/src/config/persistence.rs b/record-daemon/src/config/persistence.rs new file mode 100644 index 0000000..b1cf05d --- /dev/null +++ b/record-daemon/src/config/persistence.rs @@ -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 { + 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 { + 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 { + 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); + } +} diff --git a/record-daemon/src/config/presets.rs b/record-daemon/src/config/presets.rs new file mode 100644 index 0000000..5f21c98 --- /dev/null +++ b/record-daemon/src/config/presets.rs @@ -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, + }, +} + +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, +} + +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()); + } +} diff --git a/record-daemon/src/config/settings.rs b/record-daemon/src/config/settings.rs new file mode 100644 index 0000000..acc8f8b --- /dev/null +++ b/record-daemon/src/config/settings.rs @@ -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, + + /// 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); + } +} diff --git a/record-daemon/src/error.rs b/record-daemon/src/error.rs new file mode 100644 index 0000000..81eff3a --- /dev/null +++ b/record-daemon/src/error.rs @@ -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 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 = std::result::Result; diff --git a/record-daemon/src/ipc/handlers.rs b/record-daemon/src/ipc/handlers.rs new file mode 100644 index 0000000..f34cc2a --- /dev/null +++ b/record-daemon/src/ipc/handlers.rs @@ -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) -> Result; +} + +/// Collection of IPC command handlers. +pub struct IpcHandlers { + /// Settings reference. + settings: Arc>, + /// Recording engine reference. + recording: Arc>>, + /// Timeline store reference. + timeline: Arc>, + /// Daemon status. + status: Arc>, + /// Client connection status. + client_connected: Arc>, +} + +impl IpcHandlers { + /// Create a new handlers collection. + pub fn new( + settings: Arc>, + recording: Arc>>, + timeline: Arc>, + status: Arc>, + client_connected: Arc>, + ) -> 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 { + let settings = self.settings.read().clone(); + Ok(serde_json::to_value(settings)?) + } + + async fn handle_update_settings( + &self, + payload: Option, + ) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let recordings = self.timeline.read().get_all_recordings()?; + Ok(serde_json::to_value(recordings)?) + } + + async fn handle_get_recording(&self, id: uuid::Uuid) -> Result { + let recording = self.timeline.read().get_recording(id)?; + Ok(serde_json::to_value(recording)?) + } + + async fn handle_delete_recording(&self, id: uuid::Uuid) -> Result { + self.timeline.write().delete_recording(id)?; + Ok(serde_json::json!({ "success": true })) + } + + async fn handle_get_timeline(&self, recording_id: uuid::Uuid) -> Result { + 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 { + 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 { + 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); + } +} diff --git a/record-daemon/src/ipc/mod.rs b/record-daemon/src/ipc/mod.rs new file mode 100644 index 0000000..d5228f4 --- /dev/null +++ b/record-daemon/src/ipc/mod.rs @@ -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") +} diff --git a/record-daemon/src/ipc/protocol.rs b/record-daemon/src/ipc/protocol.rs new file mode 100644 index 0000000..dd751f0 --- /dev/null +++ b/record-daemon/src/ipc/protocol.rs @@ -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, + /// Payload data. + #[serde(skip_serializing_if = "Option::is_none")] + pub payload: Option, +} + +/// 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, + /// Error message (if failed). + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +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) -> Self { + Self { + request_id, + success: false, + data: None, + error: Some(error.into()), + } + } + + /// Serialize response to JSON string. + pub fn to_json(&self) -> Result { + 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, + champion: Option, + }, + + /// 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, + pub client_connected: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetRecordingsResponse { + pub recordings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetEncodersResponse { + pub available: Vec, + 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) -> 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 { + serde_json::from_str(json) + } + + /// Serialize to JSON string. + pub fn to_json(&self) -> Result { + 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); + } +} diff --git a/record-daemon/src/ipc/server.rs b/record-daemon/src/ipc/server.rs new file mode 100644 index 0000000..9fda977 --- /dev/null +++ b/record-daemon/src/ipc/server.rs @@ -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, + /// Command handlers. + handlers: Arc, + /// Notification broadcaster. + notification_tx: broadcast::Sender, + /// Shutdown signal. + shutdown: Arc>, + /// Connected clients count. + client_count: Arc>, +} + +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 { + 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, + shutdown: Arc>, + _notification_tx: broadcast::Sender, + ) -> 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, + shutdown: Arc>, + _notification_tx: broadcast::Sender, + ) -> 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); + } +} diff --git a/record-daemon/src/lib.rs b/record-daemon/src/lib.rs new file mode 100644 index 0000000..56ea420 --- /dev/null +++ b/record-daemon/src/lib.rs @@ -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"); diff --git a/record-daemon/src/lqp/auth.rs b/record-daemon/src/lqp/auth.rs new file mode 100644 index 0000000..8549c9a --- /dev/null +++ b/record-daemon/src/lqp/auth.rs @@ -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 { + 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::() + .map_err(|e| LqpError::LockfileParseError(format!("Invalid PID: {}", e)))?; + let port = parts[2] + .parse::() + .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, + /// Current credentials if client is running. + current: Option, +} + +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 { + 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> { + // 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> { + 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 { + 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 ")); + } +} diff --git a/record-daemon/src/lqp/client.rs b/record-daemon/src/lqp/client.rs new file mode 100644 index 0000000..1d713c1 --- /dev/null +++ b/record-daemon/src/lqp/client.rs @@ -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, + /// Current champion name. + pub champion: Option, +} + +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>>, + /// Current client state. + state: Arc>, + /// Event broadcaster. + event_sender: broadcast::Sender, + /// HTTP client for REST API. + http_client: reqwest::Client, + /// Shutdown signal. + shutdown: Arc>, +} + +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 { + 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 { + 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 { + 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>, 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 { + 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 { + 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 { + self.request("GET", endpoints::SESSION).await + } + + /// Get current summoner info. + pub async fn get_summoner(&self) -> Result { + 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())); + } +} diff --git a/record-daemon/src/lqp/events.rs b/record-daemon/src/lqp/events.rs new file mode 100644 index 0000000..92d8128 --- /dev/null +++ b/record-daemon/src/lqp/events.rs @@ -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, + + /// 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, +} + +/// 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, + + /// Player's champion name. + #[serde(default)] + pub champion: Option, + + /// Player's summoner name. + #[serde(default)] + pub summoner_name: Option, + + /// Team (100 = blue, 200 = red). + #[serde(default)] + pub team: Option, + + /// Game start timestamp. + #[serde(default = "Utc::now")] + pub timestamp: DateTime, +} + +/// 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, + + /// Victim summoner name. + pub victim: String, + + /// Victim champion name. + #[serde(default)] + pub victim_champion: Option, + + /// 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, + + /// Game time when kill occurred. + #[serde(default)] + pub game_time: Option, + + /// Real timestamp. + #[serde(default = "Utc::now")] + pub timestamp: DateTime, +} + +/// 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, + + /// Killer champion name. + #[serde(default)] + pub killer_champion: Option, + + /// Death cause (champion, minion, tower, etc.). + pub cause: String, + + /// Death position on map. + #[serde(default)] + pub position: Option, + + /// Game time when death occurred. + #[serde(default)] + pub game_time: Option, + + /// Real timestamp. + #[serde(default = "Utc::now")] + pub timestamp: DateTime, +} + +/// 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, + + /// Real timestamp. + #[serde(default = "Utc::now")] + pub timestamp: DateTime, +} + +/// 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, + + /// End timestamp. + #[serde(default = "Utc::now")] + pub timestamp: DateTime, +} + +/// 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 { + 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); + } +} diff --git a/record-daemon/src/lqp/mod.rs b/record-daemon/src/lqp/mod.rs new file mode 100644 index 0000000..fb028b7 --- /dev/null +++ b/record-daemon/src/lqp/mod.rs @@ -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, +}; diff --git a/record-daemon/src/main.rs b/record-daemon/src/main.rs new file mode 100644 index 0000000..49c2d1b --- /dev/null +++ b/record-daemon/src/main.rs @@ -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, + + /// 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, +} + +/// Main daemon structure. +struct Daemon { + /// Configuration. + settings: Arc>, + /// State machine. + state_machine: Arc, + /// LQP client. + lqp_client: Arc, + /// Recording engine. + recording_engine: Arc>>, + /// Timeline store. + timeline_store: Arc>, + /// Event mapper. + event_mapper: Arc>, + /// IPC server. + ipc_server: Option, + /// 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 +} diff --git a/record-daemon/src/recording/capture.rs b/record-daemon/src/recording/capture.rs new file mode 100644 index 0000000..31b414d --- /dev/null +++ b/record-daemon/src/recording/capture.rs @@ -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, + /// Process name to capture. + pub process_name: Option, + /// 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 { + 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, + /// 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 { + 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 { + 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 { + // 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()); + } +} diff --git a/record-daemon/src/recording/encoder.rs b/record-daemon/src/recording/encoder.rs new file mode 100644 index 0000000..cb1c1e8 --- /dev/null +++ b/record-daemon/src/recording/encoder.rs @@ -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, + /// 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 { + 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)); + } +} diff --git a/record-daemon/src/recording/mod.rs b/record-daemon/src/recording/mod.rs new file mode 100644 index 0000000..35e9174 --- /dev/null +++ b/record-daemon/src/recording/mod.rs @@ -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, + /// Current settings. + settings: Arc>, + /// Current recording output. + current_output: Option, + /// Recording start time. + start_time: Option>, + /// 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, 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 { + 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 { + 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, 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")); + } +} diff --git a/record-daemon/src/recording/obs_context.rs b/record-daemon/src/recording/obs_context.rs new file mode 100644 index 0000000..2293e23 --- /dev/null +++ b/record-daemon/src/recording/obs_context.rs @@ -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, + audio_settings: Option, + output_dir: Option, +} + +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 { + 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, +} + +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); + } +} diff --git a/record-daemon/src/recording/output.rs b/record-daemon/src/recording/output.rs new file mode 100644 index 0000000..6b98c02 --- /dev/null +++ b/record-daemon/src/recording/output.rs @@ -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, + /// Champion name if available. + pub champion: Option, +} + +/// 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, + /// Champion name if available. + pub champion: Option, + /// Recording start time. + pub start_time: DateTime, + /// Recording end time. + pub end_time: DateTime, + /// Recording duration. + // #[serde(with = "chrono::serde::seconds")] + pub duration: Duration, +} + +impl RecordingResult { + /// Get the file size in bytes. + pub fn file_size(&self) -> Option { + 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()); + } +} diff --git a/record-daemon/src/state/machine.rs b/record-daemon/src/state/machine.rs new file mode 100644 index 0000000..bd9ecca --- /dev/null +++ b/record-daemon/src/state/machine.rs @@ -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 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, + }, + /// 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>, + /// Last error message. + last_error: Arc>>, + /// Current game ID (if recording). + current_game_id: Arc>>, + /// Current champion (if recording). + current_champion: Arc>>, +} + +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 { + *self.current_game_id.read() + } + + /// Get the current champion. + pub fn current_champion(&self) -> Option { + self.current_champion.read().clone() + } + + /// Get the last error. + pub fn last_error(&self) -> Option { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/record-daemon/src/state/mod.rs b/record-daemon/src/state/mod.rs new file mode 100644 index 0000000..041fb79 --- /dev/null +++ b/record-daemon/src/state/mod.rs @@ -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"), + } + } +} diff --git a/record-daemon/src/timeline/mapper.rs b/record-daemon/src/timeline/mapper.rs new file mode 100644 index 0000000..678ccb7 --- /dev/null +++ b/record-daemon/src/timeline/mapper.rs @@ -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>, + /// Game start time (from game event). + game_start_time: Option>, +} + +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)> { + 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)> { + 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 { + self.start_time.map(|start| Utc::now() - start) + } + + /// Get the current game timestamp. + pub fn current_game_timestamp(&self) -> Option { + self.game_start_time.map(|start| Utc::now() - start) + } + + /// Get the recording duration so far. + pub fn recording_duration(&self) -> Option { + 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 { + 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); + } +} diff --git a/record-daemon/src/timeline/mod.rs b/record-daemon/src/timeline/mod.rs new file mode 100644 index 0000000..0f18699 --- /dev/null +++ b/record-daemon/src/timeline/mod.rs @@ -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, + /// Recording end time. + pub end_time: Option>, + /// Total duration in seconds. + pub duration_secs: i64, + /// Events in the timeline. + pub events: Vec, +} + +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, + ) { + 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 { + 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"); + } +} diff --git a/record-daemon/src/timeline/store.rs b/record-daemon/src/timeline/store.rs new file mode 100644 index 0000000..4569f32 --- /dev/null +++ b/record-daemon/src/timeline/store.rs @@ -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, + /// Real-world timestamp. + pub timestamp: DateTime, + /// 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, + /// Champion played. + pub champion: Option, + /// Recording start time. + pub start_time: DateTime, + /// Recording end time. + pub end_time: Option>, + /// 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, + /// 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>, + /// Timelines by recording ID. + timelines: RwLock>>, + /// 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 { + 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> { + let recordings = self.recordings.read(); + Ok(recordings.values().cloned().collect()) + } + + /// Get a specific recording. + pub fn get_recording(&self, id: Uuid) -> Result { + 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 { + 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::(&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())); + } +}