All checks were successful
record-daemon / Build, check and test (push) Successful in 2m12s
486 lines
17 KiB
Rust
486 lines
17 KiB
Rust
//! 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 (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<serde_json::Value>,
|
|
/// URI of the endpoint that triggered this event.
|
|
#[serde(default)]
|
|
pub uri: Option<String>,
|
|
}
|
|
|
|
/// 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<u64>,
|
|
/// Raw session data from `/lol-gameflow/v1/session`.
|
|
#[serde(default)]
|
|
pub raw_session: Option<serde_json::Value>,
|
|
/// Raw summoner data from `/lol-summoner/v1/current-summoner`.
|
|
#[serde(default)]
|
|
pub raw_summoner: Option<serde_json::Value>,
|
|
/// Raw champion select data from `/lol-champ-select/v1/session`.
|
|
#[serde(default)]
|
|
pub raw_champion_select: Option<serde_json::Value>,
|
|
/// Raw rune page data from `/lol-perks/v1/currentpage`.
|
|
#[serde(default)]
|
|
pub raw_rune_page: Option<serde_json::Value>,
|
|
/// Raw live client data from `/liveclientdata/allgamedata`.
|
|
#[serde(default)]
|
|
pub raw_live_client_data: Option<serde_json::Value>,
|
|
/// Raw end-of-game stats from `/lol-end-of-game/v1/eog-stats-block`.
|
|
#[serde(default)]
|
|
pub raw_end_game_stats: Option<serde_json::Value>,
|
|
/// Recording start time.
|
|
pub start_time: DateTime<Utc>,
|
|
/// Recording end time.
|
|
pub end_time: Option<DateTime<Utc>>,
|
|
/// Recording duration.
|
|
pub duration: Duration,
|
|
/// Output file path.
|
|
pub file_path: Option<PathBuf>,
|
|
/// Video file name (just the filename, not the full path).
|
|
pub video_file: Option<String>,
|
|
/// 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,
|
|
}
|
|
|
|
/// Update for recording metadata.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct MetadataUpdate {
|
|
pub game_id: Option<u64>,
|
|
pub raw_session: Option<serde_json::Value>,
|
|
pub raw_summoner: Option<serde_json::Value>,
|
|
pub raw_champion_select: Option<serde_json::Value>,
|
|
pub raw_rune_page: Option<serde_json::Value>,
|
|
pub raw_live_client_data: Option<serde_json::Value>,
|
|
pub raw_end_game_stats: Option<serde_json::Value>,
|
|
}
|
|
|
|
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<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,
|
|
}
|
|
}
|
|
|
|
/// 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<u64>, _champion: Option<String>) -> 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<Uuid> {
|
|
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<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,
|
|
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::<super::Timeline>(&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));
|
|
}
|
|
}
|