record-daemon: initial commit
This commit is contained in:
222
record-daemon/src/timeline/mapper.rs
Normal file
222
record-daemon/src/timeline/mapper.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
//! Event mapper for mapping game events to video timestamps.
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::lqp::GameEvent;
|
||||
|
||||
/// Event mapper that tracks recording start time and maps events to video timestamps.
|
||||
pub struct EventMapper {
|
||||
/// Recording start time.
|
||||
start_time: Option<DateTime<Utc>>,
|
||||
/// Game start time (from game event).
|
||||
game_start_time: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl EventMapper {
|
||||
/// Create a new event mapper.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
start_time: None,
|
||||
game_start_time: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the mapper (recording started).
|
||||
pub fn start(&mut self) {
|
||||
self.start_time = Some(Utc::now());
|
||||
debug!("Event mapper started at {:?}", self.start_time);
|
||||
}
|
||||
|
||||
/// Stop the mapper (recording stopped).
|
||||
pub fn stop(&mut self) {
|
||||
self.start_time = None;
|
||||
self.game_start_time = None;
|
||||
debug!("Event mapper stopped");
|
||||
}
|
||||
|
||||
/// Check if the mapper is active.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.start_time.is_some()
|
||||
}
|
||||
|
||||
/// Map a game event to video and game timestamps.
|
||||
pub fn map_event(&self, event: &GameEvent) -> Option<(Duration, Option<Duration>)> {
|
||||
let start_time = self.start_time?;
|
||||
let now = Utc::now();
|
||||
|
||||
// Calculate video timestamp (time since recording started)
|
||||
let video_timestamp = now - start_time;
|
||||
|
||||
// Calculate game timestamp if we have game start time
|
||||
let game_timestamp = self.game_start_time.map(|game_start| now - game_start);
|
||||
|
||||
// Update game start time if this is a game start event
|
||||
// (handled separately in handle_event)
|
||||
|
||||
Some((video_timestamp, game_timestamp))
|
||||
}
|
||||
|
||||
/// Handle a game event and return mapped timestamps.
|
||||
pub fn handle_event(&mut self, event: &GameEvent) -> Option<(Duration, Option<Duration>)> {
|
||||
if !self.is_active() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Track game start time
|
||||
if let GameEvent::GameStart(_) = event {
|
||||
self.game_start_time = Some(Utc::now());
|
||||
debug!("Game start time recorded: {:?}", self.game_start_time);
|
||||
}
|
||||
|
||||
self.map_event(event)
|
||||
}
|
||||
|
||||
/// Get the current video timestamp.
|
||||
pub fn current_video_timestamp(&self) -> Option<Duration> {
|
||||
self.start_time.map(|start| Utc::now() - start)
|
||||
}
|
||||
|
||||
/// Get the current game timestamp.
|
||||
pub fn current_game_timestamp(&self) -> Option<Duration> {
|
||||
self.game_start_time.map(|start| Utc::now() - start)
|
||||
}
|
||||
|
||||
/// Get the recording duration so far.
|
||||
pub fn recording_duration(&self) -> Option<Duration> {
|
||||
self.current_video_timestamp()
|
||||
}
|
||||
|
||||
/// Reset the mapper.
|
||||
pub fn reset(&mut self) {
|
||||
self.start_time = None;
|
||||
self.game_start_time = None;
|
||||
debug!("Event mapper reset");
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventMapper {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Event synchronizer for keeping video and game time in sync.
|
||||
///
|
||||
/// This handles cases where the game time might drift from real time,
|
||||
/// such as when the game pauses or lags.
|
||||
pub struct EventSynchronizer {
|
||||
/// Known sync points (video timestamp, game timestamp).
|
||||
sync_points: Vec<(Duration, Duration)>,
|
||||
/// Maximum allowed drift in seconds.
|
||||
max_drift_secs: f64,
|
||||
}
|
||||
|
||||
impl EventSynchronizer {
|
||||
/// Create a new event synchronizer.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sync_points: Vec::new(),
|
||||
max_drift_secs: 5.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a sync point.
|
||||
pub fn add_sync_point(&mut self, video_ts: Duration, game_ts: Duration) {
|
||||
self.sync_points.push((video_ts, game_ts));
|
||||
|
||||
// Keep only recent sync points
|
||||
if self.sync_points.len() > 100 {
|
||||
self.sync_points.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the drift between video and game time.
|
||||
pub fn calculate_drift(&self) -> Option<Duration> {
|
||||
if self.sync_points.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let first = self.sync_points.first()?;
|
||||
let last = self.sync_points.last()?;
|
||||
|
||||
let video_diff = last.0 - first.0;
|
||||
let game_diff = last.1 - first.1;
|
||||
|
||||
Some(video_diff - game_diff)
|
||||
}
|
||||
|
||||
/// Check if the drift is within acceptable bounds.
|
||||
pub fn is_drift_acceptable(&self) -> bool {
|
||||
self.calculate_drift()
|
||||
.map(|drift| (drift.num_seconds().abs() as f64) < self.max_drift_secs)
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Adjust a game timestamp based on known drift.
|
||||
pub fn adjust_game_timestamp(&self, game_ts: Duration) -> Duration {
|
||||
if let Some(drift) = self.calculate_drift() {
|
||||
game_ts + drift
|
||||
} else {
|
||||
game_ts
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the synchronizer.
|
||||
pub fn reset(&mut self) {
|
||||
self.sync_points.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventSynchronizer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
#[test]
|
||||
fn test_event_mapper_start_stop() {
|
||||
let mut mapper = EventMapper::new();
|
||||
|
||||
assert!(!mapper.is_active());
|
||||
|
||||
mapper.start();
|
||||
assert!(mapper.is_active());
|
||||
|
||||
mapper.stop();
|
||||
assert!(!mapper.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_mapper_timestamps() {
|
||||
let mut mapper = EventMapper::new();
|
||||
mapper.start();
|
||||
|
||||
sleep(StdDuration::from_millis(100));
|
||||
|
||||
let ts = mapper.current_video_timestamp().unwrap();
|
||||
assert!(ts.num_milliseconds() >= 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_synchronizer() {
|
||||
let mut sync = EventSynchronizer::new();
|
||||
|
||||
sync.add_sync_point(Duration::seconds(0), Duration::seconds(0));
|
||||
sync.add_sync_point(Duration::seconds(10), Duration::seconds(10));
|
||||
|
||||
assert!(sync.is_drift_acceptable());
|
||||
|
||||
// Add a drift
|
||||
sync.add_sync_point(Duration::seconds(20), Duration::seconds(18));
|
||||
|
||||
let drift = sync.calculate_drift().unwrap();
|
||||
assert_eq!(drift.num_seconds(), 2);
|
||||
}
|
||||
}
|
||||
193
record-daemon/src/timeline/mod.rs
Normal file
193
record-daemon/src/timeline/mod.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
//! Timeline module for storing game events and mapping them to video timestamps.
|
||||
|
||||
mod mapper;
|
||||
mod store;
|
||||
|
||||
pub use mapper::EventMapper;
|
||||
pub use store::{RecordingMetadata, TimelineStore, TimestampedEvent};
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::lqp::GameEvent;
|
||||
|
||||
/// A timeline of events for a recording.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Timeline {
|
||||
/// Recording ID.
|
||||
pub recording_id: Uuid,
|
||||
/// Recording start time.
|
||||
pub start_time: DateTime<Utc>,
|
||||
/// Recording end time.
|
||||
pub end_time: Option<DateTime<Utc>>,
|
||||
/// Total duration in seconds.
|
||||
pub duration_secs: i64,
|
||||
/// Events in the timeline.
|
||||
pub events: Vec<TimestampedEvent>,
|
||||
}
|
||||
|
||||
impl Timeline {
|
||||
/// Get the duration as chrono Duration.
|
||||
pub fn duration(&self) -> Duration {
|
||||
Duration::seconds(self.duration_secs)
|
||||
}
|
||||
}
|
||||
|
||||
impl Timeline {
|
||||
/// Create a new timeline for a recording.
|
||||
pub fn new(recording_id: Uuid) -> Self {
|
||||
Self {
|
||||
recording_id,
|
||||
start_time: Utc::now(),
|
||||
end_time: None,
|
||||
duration_secs: 0,
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an event to the timeline.
|
||||
pub fn add_event(
|
||||
&mut self,
|
||||
event: GameEvent,
|
||||
video_timestamp: Duration,
|
||||
game_timestamp: Option<Duration>,
|
||||
) {
|
||||
let timestamped = TimestampedEvent {
|
||||
video_timestamp,
|
||||
game_timestamp,
|
||||
timestamp: Utc::now(),
|
||||
event_type: event_type_name(&event),
|
||||
description: event.description(),
|
||||
event,
|
||||
};
|
||||
|
||||
self.events.push(timestamped);
|
||||
}
|
||||
|
||||
/// Finalize the timeline.
|
||||
pub fn finalize(&mut self) {
|
||||
self.end_time = Some(Utc::now());
|
||||
self.duration_secs = (self.end_time.unwrap() - self.start_time).num_seconds();
|
||||
}
|
||||
|
||||
/// Get events within a time range.
|
||||
pub fn events_in_range(&self, start: Duration, end: Duration) -> Vec<&TimestampedEvent> {
|
||||
self.events
|
||||
.iter()
|
||||
.filter(|e| e.video_timestamp >= start && e.video_timestamp <= end)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get events of a specific type.
|
||||
pub fn events_of_type(&self, event_type: &str) -> Vec<&TimestampedEvent> {
|
||||
self.events
|
||||
.iter()
|
||||
.filter(|e| e.event_type == event_type)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the number of events.
|
||||
pub fn event_count(&self) -> usize {
|
||||
self.events.len()
|
||||
}
|
||||
|
||||
/// Export timeline to JSON.
|
||||
pub fn to_json(&self) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string_pretty(self)
|
||||
}
|
||||
|
||||
/// Export timeline to CSV.
|
||||
pub fn to_csv(&self) -> String {
|
||||
let mut csv =
|
||||
String::from("video_timestamp,game_timestamp,event_type,description,timestamp\n");
|
||||
|
||||
for event in &self.events {
|
||||
let video_ts = format_timestamp(event.video_timestamp);
|
||||
let game_ts = event
|
||||
.game_timestamp
|
||||
.map(format_timestamp)
|
||||
.unwrap_or_default();
|
||||
|
||||
csv.push_str(&format!(
|
||||
"{},{},{},{},{}\n",
|
||||
video_ts,
|
||||
game_ts,
|
||||
event.event_type,
|
||||
event.description,
|
||||
event.timestamp.to_rfc3339(),
|
||||
));
|
||||
}
|
||||
|
||||
csv
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a duration as HH:MM:SS.mmm
|
||||
fn format_timestamp(duration: Duration) -> String {
|
||||
let total_ms = duration.num_milliseconds();
|
||||
let hours = total_ms / 3_600_000;
|
||||
let minutes = (total_ms % 3_600_000) / 60_000;
|
||||
let seconds = (total_ms % 60_000) / 1000;
|
||||
let millis = total_ms % 1000;
|
||||
|
||||
format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, millis)
|
||||
}
|
||||
|
||||
/// Get the event type name.
|
||||
fn event_type_name(event: &GameEvent) -> String {
|
||||
match event {
|
||||
GameEvent::MatchFound(_) => "match_found",
|
||||
GameEvent::GameStart(_) => "game_start",
|
||||
GameEvent::Kill(_) => "kill",
|
||||
GameEvent::Death(_) => "death",
|
||||
GameEvent::Objective(_) => "objective",
|
||||
GameEvent::GameEnd(_) => "game_end",
|
||||
GameEvent::Unknown => "unknown",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::lqp::{KillEvent, ObjectiveEvent, ObjectiveType};
|
||||
|
||||
#[test]
|
||||
fn test_timeline_creation() {
|
||||
let id = Uuid::new_v4();
|
||||
let timeline = Timeline::new(id);
|
||||
|
||||
assert_eq!(timeline.recording_id, id);
|
||||
assert!(timeline.events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_event() {
|
||||
let id = Uuid::new_v4();
|
||||
let mut timeline = Timeline::new(id);
|
||||
|
||||
let event = GameEvent::Kill(KillEvent {
|
||||
killer: "Player1".to_string(),
|
||||
killer_champion: Some("Ahri".to_string()),
|
||||
victim: "Player2".to_string(),
|
||||
victim_champion: Some("Lux".to_string()),
|
||||
solo_kill: true,
|
||||
assists: 0,
|
||||
position: None,
|
||||
game_time: Some(120.0),
|
||||
timestamp: Utc::now(),
|
||||
});
|
||||
|
||||
timeline.add_event(event, Duration::seconds(5), Some(Duration::seconds(120)));
|
||||
|
||||
assert_eq!(timeline.event_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_timestamp() {
|
||||
let duration = Duration::milliseconds(3723456); // 1:02:03.456
|
||||
let formatted = format_timestamp(duration);
|
||||
assert_eq!(formatted, "01:02:03.456");
|
||||
}
|
||||
}
|
||||
311
record-daemon/src/timeline/store.rs
Normal file
311
record-daemon/src/timeline/store.rs
Normal file
@@ -0,0 +1,311 @@
|
||||
//! Timeline storage backend.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{Result, TimelineError};
|
||||
use crate::lqp::GameEvent;
|
||||
use crate::recording::RecordingResult;
|
||||
|
||||
/// A timestamped event in the timeline.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TimestampedEvent {
|
||||
/// Video timestamp (offset from recording start).
|
||||
// #[serde(with = "chrono::serde::seconds")]
|
||||
pub video_timestamp: Duration,
|
||||
/// Game timestamp (in-game time).
|
||||
// #[serde(with = "chrono::serde::seconds_option")]
|
||||
pub game_timestamp: Option<Duration>,
|
||||
/// Real-world timestamp.
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// Event type name.
|
||||
pub event_type: String,
|
||||
/// Human-readable description.
|
||||
pub description: String,
|
||||
/// The actual event data.
|
||||
pub event: GameEvent,
|
||||
}
|
||||
|
||||
/// Metadata for a recording.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecordingMetadata {
|
||||
/// Unique recording ID.
|
||||
pub id: Uuid,
|
||||
/// Game ID if available.
|
||||
pub game_id: Option<u64>,
|
||||
/// Champion played.
|
||||
pub champion: Option<String>,
|
||||
/// Recording start time.
|
||||
pub start_time: DateTime<Utc>,
|
||||
/// Recording end time.
|
||||
pub end_time: Option<DateTime<Utc>>,
|
||||
/// Recording duration.
|
||||
// #[serde(with = "chrono::serde::seconds")]
|
||||
pub duration: Duration,
|
||||
/// Output file path.
|
||||
pub file_path: PathBuf,
|
||||
/// File size in bytes.
|
||||
pub file_size: Option<u64>,
|
||||
/// Number of events.
|
||||
pub event_count: usize,
|
||||
/// Whether the timeline has been finalized.
|
||||
pub finalized: bool,
|
||||
}
|
||||
|
||||
impl RecordingMetadata {
|
||||
/// Create metadata from a recording result.
|
||||
pub fn from_result(result: &RecordingResult) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
game_id: result.game_id,
|
||||
champion: result.champion.clone(),
|
||||
start_time: result.start_time,
|
||||
end_time: Some(result.end_time),
|
||||
duration: result.duration,
|
||||
file_path: result.path.clone(),
|
||||
file_size: result.file_size(),
|
||||
event_count: 0,
|
||||
finalized: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory timeline storage.
|
||||
pub struct TimelineStore {
|
||||
/// Recording metadata by ID.
|
||||
recordings: RwLock<HashMap<Uuid, RecordingMetadata>>,
|
||||
/// Timelines by recording ID.
|
||||
timelines: RwLock<HashMap<Uuid, Vec<TimestampedEvent>>>,
|
||||
/// Storage directory for persistence.
|
||||
storage_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl TimelineStore {
|
||||
/// Create a new timeline store.
|
||||
pub fn new() -> Self {
|
||||
let storage_dir = crate::config::get_default_output_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("./recordings"))
|
||||
.join("timelines");
|
||||
|
||||
Self {
|
||||
recordings: RwLock::new(HashMap::new()),
|
||||
timelines: RwLock::new(HashMap::new()),
|
||||
storage_dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a timeline store with a specific storage directory.
|
||||
pub fn with_dir(storage_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
recordings: RwLock::new(HashMap::new()),
|
||||
timelines: RwLock::new(HashMap::new()),
|
||||
storage_dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a new recording.
|
||||
pub fn add_recording(&self, result: RecordingResult) -> Result<Uuid> {
|
||||
let id = Uuid::new_v4();
|
||||
let metadata = RecordingMetadata {
|
||||
id,
|
||||
game_id: result.game_id,
|
||||
champion: result.champion.clone(),
|
||||
start_time: result.start_time,
|
||||
end_time: Some(result.end_time),
|
||||
duration: result.duration,
|
||||
file_path: result.path.clone(),
|
||||
file_size: result.file_size(),
|
||||
event_count: 0,
|
||||
finalized: true,
|
||||
};
|
||||
|
||||
self.recordings.write().insert(id, metadata);
|
||||
self.timelines.write().insert(id, Vec::new());
|
||||
|
||||
// Persist to disk
|
||||
self.persist_recording(id)?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Add an event to a recording's timeline.
|
||||
pub fn add_event(&self, recording_id: Uuid, event: TimestampedEvent) -> Result<()> {
|
||||
let mut timelines = self.timelines.write();
|
||||
|
||||
let timeline = timelines
|
||||
.get_mut(&recording_id)
|
||||
.ok_or(TimelineError::RecordingNotFound(recording_id))?;
|
||||
|
||||
timeline.push(event);
|
||||
|
||||
// Update event count in metadata
|
||||
drop(timelines);
|
||||
let mut recordings = self.recordings.write();
|
||||
if let Some(metadata) = recordings.get_mut(&recording_id) {
|
||||
metadata.event_count += 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get all recordings.
|
||||
pub fn get_all_recordings(&self) -> Result<Vec<RecordingMetadata>> {
|
||||
let recordings = self.recordings.read();
|
||||
Ok(recordings.values().cloned().collect())
|
||||
}
|
||||
|
||||
/// Get a specific recording.
|
||||
pub fn get_recording(&self, id: Uuid) -> Result<RecordingMetadata> {
|
||||
self.recordings
|
||||
.read()
|
||||
.get(&id)
|
||||
.cloned()
|
||||
.ok_or_else(|| TimelineError::RecordingNotFound(id).into())
|
||||
}
|
||||
|
||||
/// Get the timeline for a recording.
|
||||
pub fn get_timeline(&self, recording_id: Uuid) -> Result<super::Timeline> {
|
||||
let recordings = self.recordings.read();
|
||||
let metadata = recordings
|
||||
.get(&recording_id)
|
||||
.ok_or(TimelineError::RecordingNotFound(recording_id))?;
|
||||
|
||||
let timelines = self.timelines.read();
|
||||
let events = timelines.get(&recording_id).cloned().unwrap_or_default();
|
||||
|
||||
Ok(super::Timeline {
|
||||
recording_id,
|
||||
start_time: metadata.start_time,
|
||||
end_time: metadata.end_time,
|
||||
duration_secs: metadata.duration.num_seconds(),
|
||||
events,
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete a recording and its timeline.
|
||||
pub fn delete_recording(&self, id: Uuid) -> Result<()> {
|
||||
// Remove from memory
|
||||
self.recordings.write().remove(&id);
|
||||
self.timelines.write().remove(&id);
|
||||
|
||||
// Remove from disk
|
||||
let timeline_file = self.storage_dir.join(format!("{}.json", id));
|
||||
if timeline_file.exists() {
|
||||
std::fs::remove_file(&timeline_file)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist a recording to disk.
|
||||
fn persist_recording(&self, id: Uuid) -> Result<()> {
|
||||
// Ensure storage directory exists
|
||||
if !self.storage_dir.exists() {
|
||||
std::fs::create_dir_all(&self.storage_dir)?;
|
||||
}
|
||||
|
||||
let metadata = self.recordings.read().get(&id).cloned();
|
||||
|
||||
let events = self.timelines.read().get(&id).cloned();
|
||||
|
||||
if let (Some(metadata), Some(events)) = (metadata, events) {
|
||||
let timeline = super::Timeline {
|
||||
recording_id: id,
|
||||
start_time: metadata.start_time,
|
||||
end_time: metadata.end_time,
|
||||
duration_secs: metadata.duration.num_seconds(),
|
||||
events,
|
||||
};
|
||||
|
||||
let file_path = self.storage_dir.join(format!("{}.json", id));
|
||||
let json = serde_json::to_string_pretty(&timeline)?;
|
||||
std::fs::write(&file_path, json)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load all recordings from disk.
|
||||
pub fn load_from_disk(&self) -> Result<()> {
|
||||
if !self.storage_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for entry in std::fs::read_dir(&self.storage_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||
if let Ok(contents) = std::fs::read_to_string(&path) {
|
||||
if let Ok(timeline) = serde_json::from_str::<super::Timeline>(&contents) {
|
||||
let metadata = RecordingMetadata {
|
||||
id: timeline.recording_id,
|
||||
game_id: None,
|
||||
champion: None,
|
||||
start_time: timeline.start_time,
|
||||
end_time: timeline.end_time,
|
||||
duration: timeline.duration(),
|
||||
file_path: PathBuf::new(),
|
||||
file_size: None,
|
||||
event_count: timeline.events.len(),
|
||||
finalized: true,
|
||||
};
|
||||
|
||||
self.recordings
|
||||
.write()
|
||||
.insert(timeline.recording_id, metadata);
|
||||
self.timelines
|
||||
.write()
|
||||
.insert(timeline.recording_id, timeline.events);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TimelineStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_timeline_store_creation() {
|
||||
let store = TimelineStore::new();
|
||||
let recordings = store.get_all_recordings().unwrap();
|
||||
assert!(recordings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_recording() {
|
||||
let store = TimelineStore::new();
|
||||
|
||||
let result = RecordingResult {
|
||||
path: PathBuf::from("/tmp/test.mp4"),
|
||||
game_id: Some(12345),
|
||||
champion: Some("Ahri".to_string()),
|
||||
start_time: Utc::now(),
|
||||
end_time: Utc::now() + Duration::seconds(60),
|
||||
duration: Duration::seconds(60),
|
||||
};
|
||||
|
||||
let id = store.add_recording(result).unwrap();
|
||||
let recordings = store.get_all_recordings().unwrap();
|
||||
assert_eq!(recordings.len(), 1);
|
||||
|
||||
let metadata = store.get_recording(id).unwrap();
|
||||
assert_eq!(metadata.champion, Some("Ahri".to_string()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user