//! 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 (derived from URI). pub event_type: String, /// Human-readable description. pub description: String, /// The actual event data. pub event: GameEvent, /// Raw JSON data from the API (for flexibility). #[serde(default)] pub raw_data: Option, /// URI of the endpoint that triggered this event. #[serde(default)] pub uri: Option, } /// Metadata for a recording. /// Stores raw API responses for maximum flexibility and future-proofing. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RecordingMetadata { /// Unique recording ID. pub id: Uuid, /// Game ID if available. pub game_id: Option, /// Raw session data from `/lol-gameflow/v1/session`. #[serde(default)] pub raw_session: Option, /// Raw summoner data from `/lol-summoner/v1/current-summoner`. #[serde(default)] pub raw_summoner: Option, /// Raw champion select data from `/lol-champ-select/v1/session`. #[serde(default)] pub raw_champion_select: Option, /// Raw rune page data from `/lol-perks/v1/currentpage`. #[serde(default)] pub raw_rune_page: Option, /// Raw live client data from `/liveclientdata/allgamedata`. #[serde(default)] pub raw_live_client_data: Option, /// Raw end-of-game stats from `/lol-end-of-game/v1/eog-stats-block`. #[serde(default)] pub raw_end_game_stats: Option, /// Recording start time. pub start_time: DateTime, /// Recording end time. pub end_time: Option>, /// Recording duration. pub duration: Duration, /// Output file path. pub file_path: Option, /// Video file name (just the filename, not the full path). pub video_file: Option, /// File size in bytes. pub file_size: Option, /// Number of events. pub event_count: usize, /// Whether the timeline has been finalized. pub finalized: bool, } /// Update for recording metadata. #[derive(Debug, Clone, Default)] pub struct MetadataUpdate { pub game_id: Option, pub raw_session: Option, pub raw_summoner: Option, pub raw_champion_select: Option, pub raw_rune_page: Option, pub raw_live_client_data: Option, pub raw_end_game_stats: Option, } 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, raw_session: None, raw_summoner: None, raw_champion_select: None, raw_rune_page: None, raw_live_client_data: None, raw_end_game_stats: None, start_time: result.start_time, end_time: Some(result.end_time), duration: result.duration, file_path: Some(result.path.clone()), video_file: result .path .file_name() .and_then(|n| n.to_str()) .map(String::from), 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, } } /// Start a new recording entry (called when recording begins). /// Returns the recording ID for tracking events during recording. pub fn start_recording_entry(&self, game_id: Option, _champion: Option) -> Uuid { let id = Uuid::new_v4(); let metadata = RecordingMetadata { id, game_id, raw_session: None, raw_summoner: None, raw_champion_select: None, raw_rune_page: None, raw_live_client_data: None, raw_end_game_stats: None, start_time: Utc::now(), end_time: None, duration: Duration::zero(), file_path: None, video_file: None, file_size: None, event_count: 0, finalized: false, }; self.recordings.write().insert(id, metadata); self.timelines.write().insert(id, Vec::new()); id } /// Finalize a recording with the recording result. /// Called when recording stops. pub fn finalize_recording(&self, recording_id: Uuid, result: RecordingResult) -> Result<()> { let mut recordings = self.recordings.write(); let metadata = recordings .get_mut(&recording_id) .ok_or(TimelineError::RecordingNotFound(recording_id))?; // Update with final recording data metadata.start_time = result.start_time; metadata.end_time = Some(result.end_time); metadata.duration = result.duration; metadata.file_path = Some(result.path.clone()); metadata.file_size = result.file_size(); metadata.finalized = true; // Persist to disk drop(recordings); self.persist_recording(recording_id)?; Ok(()) } /// Add a new recording (legacy method for backwards compatibility). pub fn add_recording(&self, result: RecordingResult) -> Result { let id = Uuid::new_v4(); let metadata = RecordingMetadata { id, game_id: result.game_id, raw_session: None, raw_summoner: None, raw_champion_select: None, raw_rune_page: None, raw_live_client_data: None, raw_end_game_stats: None, start_time: result.start_time, end_time: Some(result.end_time), duration: result.duration, file_path: Some(result.path.clone()), video_file: result .path .file_name() .and_then(|n| n.to_str()) .map(String::from), 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(()) } /// Update metadata for a recording. pub fn update_metadata(&self, recording_id: Uuid, update: MetadataUpdate) -> Result<()> { let mut recordings = self.recordings.write(); if let Some(metadata) = recordings.get_mut(&recording_id) { if let Some(game_id) = update.game_id { metadata.game_id = Some(game_id); } if let Some(raw_session) = update.raw_session { metadata.raw_session = Some(raw_session); } if let Some(raw_summoner) = update.raw_summoner { metadata.raw_summoner = Some(raw_summoner); } if let Some(raw_champion_select) = update.raw_champion_select { metadata.raw_champion_select = Some(raw_champion_select); } if let Some(raw_rune_page) = update.raw_rune_page { metadata.raw_rune_page = Some(raw_rune_page); } if let Some(raw_live_client_data) = update.raw_live_client_data { metadata.raw_live_client_data = Some(raw_live_client_data); } if let Some(raw_end_game_stats) = update.raw_end_game_stats { metadata.raw_end_game_stats = Some(raw_end_game_stats); } } drop(recordings); self.persist_recording(recording_id)?; 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, game_id: metadata.game_id, raw_session: metadata.raw_session.clone(), raw_summoner: metadata.raw_summoner.clone(), raw_champion_select: metadata.raw_champion_select.clone(), raw_rune_page: metadata.raw_rune_page.clone(), raw_live_client_data: metadata.raw_live_client_data.clone(), raw_end_game_stats: metadata.raw_end_game_stats.clone(), video_file: metadata.video_file.clone(), }) } /// 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, game_id: metadata.game_id, raw_session: metadata.raw_session, raw_summoner: metadata.raw_summoner, raw_champion_select: metadata.raw_champion_select, raw_rune_page: metadata.raw_rune_page, raw_live_client_data: metadata.raw_live_client_data, raw_end_game_stats: metadata.raw_end_game_stats, video_file: metadata.video_file, }; 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 recording_id = timeline.recording_id; let start_time = timeline.start_time; let end_time = timeline.end_time; let duration = timeline.duration(); let event_count = timeline.events.len(); let game_id = timeline.game_id; let raw_session = timeline.raw_session; let raw_summoner = timeline.raw_summoner; let raw_champion_select = timeline.raw_champion_select; let raw_rune_page = timeline.raw_rune_page; let raw_live_client_data = timeline.raw_live_client_data; let raw_end_game_stats = timeline.raw_end_game_stats; let video_file = timeline.video_file; let events = timeline.events; let metadata = RecordingMetadata { id: recording_id, game_id, raw_session, raw_summoner, raw_champion_select, raw_rune_page, raw_live_client_data, raw_end_game_stats, start_time, end_time, duration, file_path: None, video_file, file_size: None, event_count, finalized: true, }; self.recordings.write().insert(recording_id, metadata); self.timelines.write().insert(recording_id, 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), stats: None, }; 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.game_id, Some(12345)); } }