record-daemon: initial commit

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

View File

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