//! Atomic session metadata; segment paths are relative to their session directory. use crate::{ Library, Settings, library::{Session, Span, SpanKind}, }; use serde::{Deserialize, Serialize}; use std::{ fs, io, path::{Path, PathBuf}, time::{Duration, SystemTime, UNIX_EPOCH}, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Segment { pub path: PathBuf, pub start: Duration, pub end: Duration, pub complete: bool, /// False for a header-only file left by a split racing with stop. #[serde(default = "available_default")] pub available: bool, #[serde(default)] pub frame_start: u64, #[serde(default = "default_fps")] pub frames_per_second: u32, /// Cumulative dropped frames in this run when the segment completed. #[serde(default)] pub dropped_frames: u64, /// Exact cumulative AAC samples before this file, independently per audio stream. #[serde(default)] pub audio_start_samples: Vec, } fn available_default() -> bool { true } fn default_fps() -> u32 { 60 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Metadata { pub session: Session, pub app_id: u32, pub segments: Vec, } const METADATA_FILE: &str = "session.json"; #[derive(Clone)] pub struct Store { pub root: PathBuf, } pub fn config_path() -> io::Result { Ok(dirs::config_dir() .ok_or_else(|| io::Error::other("No configuration directory"))? .join("clipbin/settings.json")) } pub fn load_settings() -> io::Result { match fs::read(config_path()?) { Ok(bytes) => serde_json::from_slice(&bytes).map_err(io::Error::other), Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Settings::default()), Err(e) => Err(e), } } pub fn save_settings(settings: &Settings) -> io::Result<()> { atomic_json(&config_path()?, settings) } /// Same-directory replacement keeps readers and crash recovery away from partial JSON. pub fn atomic_json(path: &Path, value: &impl Serialize) -> io::Result<()> { fs::create_dir_all( path.parent() .ok_or_else(|| io::Error::other("No parent directory"))?, )?; let mut temporary = tempfile::NamedTempFile::new_in(path.parent().unwrap())?; serde_json::to_writer_pretty(temporary.as_file_mut(), value).map_err(io::Error::other)?; temporary.as_file().sync_all()?; temporary.persist(path).map_err(|e| e.error)?; #[cfg(unix)] fs::File::open(path.parent().unwrap())?.sync_all()?; Ok(()) } /// Frame offsets never depend on container timestamps (each segment starts at zero). pub fn frame_offset(frames: u64, fps: u32) -> Duration { let fps = u64::from(fps.max(1)); Duration::new(frames / fps, ((frames % fps) * 1_000_000_000 / fps) as u32) } /// The keyframe triggering `file_changed` is counted already, but is the new /// segment's first frame. Zero identifies the initial `OutputStarted` segment. pub fn segment_offset(total_frames: u64, fps: u32) -> Duration { frame_offset(total_frames.saturating_sub(1), fps) } impl Store { pub fn discover() -> io::Result { let root = dirs::data_local_dir() .ok_or_else(|| io::Error::other("No data directory"))? .join(if cfg!(windows) { "Clipbin" } else { "clipbin" }); Ok(Self { root: root.join("sessions"), }) } pub fn directory(&self, id: u64) -> PathBuf { self.root.join(id.to_string()) } pub fn save(&self, metadata: &Metadata) -> io::Result<()> { atomic_json( &self.directory(metadata.session.id).join(METADATA_FILE), metadata, ) } pub fn create(&self, app_id: u32, game: String) -> io::Result { let started = SystemTime::now(); let mut id = started .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; while self.directory(id).exists() { id += 1; } let metadata = Metadata { session: Session { id, game, started, length: Duration::ZERO, active: true, spans: vec![], bookmarks: vec![], }, app_id, segments: vec![], }; self.save(&metadata)?; Ok(metadata) } pub fn read(&self) -> io::Result> { let entries = match fs::read_dir(&self.root) { Ok(e) => e, Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(vec![]), Err(e) => return Err(e), }; let mut sessions = vec![]; for entry in entries { let path = entry?.path().join(METADATA_FILE); if path.exists() { let bytes = fs::read(&path)?; sessions.push( serde_json::from_slice(&bytes) .map_err(|e| io::Error::other(format!("{}: {e}", path.display())))?, ); } } Ok(sessions) } /// Active metadata cannot belong to a live recorder during initial startup. pub fn recover(&self) -> io::Result<()> { for mut metadata in self.read()? { if metadata.session.active { metadata.session.active = false; for segment in &mut metadata.segments { segment.complete = true; } self.save(&metadata)?; } } Ok(()) } /// One session's metadata, with segment paths resolved against its directory. pub fn metadata(&self, id: u64) -> io::Result { let directory = self.directory(id); let mut metadata: Metadata = serde_json::from_slice(&fs::read(directory.join(METADATA_FILE))?) .map_err(io::Error::other)?; for segment in &mut metadata.segments { segment.path = directory.join(&segment.path); } Ok(metadata) } pub fn library(&self, budget: u64) -> io::Result { self.library_with_protection(budget, &[]) } pub fn library_with_protection( &self, budget: u64, ranges: &[(u64, Duration, Duration)], ) -> io::Result { let mut library = Library::empty(budget); for metadata in self.read()? { let leased: Vec<_> = ranges .iter() .filter(|(id, _, _)| *id == metadata.session.id) .map(|(_, start, end)| (*start, *end)) .collect(); for segment in &metadata.segments { let bytes = fs::metadata(self.directory(metadata.session.id).join(&segment.path)) .map_or(0, |f| f.len()); if crate::cleanup::protected(segment, &metadata.session.bookmarks, &leased) { library.storage.protected_bytes += bytes; } else { library.storage.unprotected_bytes += bytes; } } library.sessions.push(metadata.session); } for saved in self.clips()? { library.storage.clip_bytes += saved.clip.size_bytes; library.clips.push(saved.clip); } library .sessions .sort_by_key(|s| std::cmp::Reverse(s.started)); Ok(library) } } /// A point in a segment file: the file and the offset into it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Located<'a> { pub index: usize, pub segment: &'a Segment, /// Time within the file. Each file's timestamps start at zero. pub offset: Duration, } /// The part of one segment that falls inside a requested range. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Piece<'a> { pub index: usize, pub segment: &'a Segment, /// Range within the file, from its own zero. pub from: Duration, pub to: Duration, /// Where `from` sits on the session timeline. pub session_start: Duration, } impl Metadata { /// Segments that hold footage, in session order. fn playable(&self) -> impl Iterator { self.segments .iter() .enumerate() .filter(|(_, segment)| segment.available && segment.end > segment.start) } /// Finds the footage at session time `time`. `None` inside gaps (missing or /// cleaned-up time) and beyond the last footage. pub fn locate(&self, time: Duration) -> Option> { self.playable() .find(|(_, segment)| segment.start <= time && time < segment.end) .map(|(index, segment)| Located { index, segment, offset: time - segment.start, }) } /// The footage between `start` and `end`, as pieces of segment files in /// order. Gaps simply produce no pieces. pub fn covering(&self, start: Duration, end: Duration) -> Vec> { self.playable() .filter(|(_, segment)| segment.start < end && start < segment.end) .map(|(index, segment)| { let session_start = start.max(segment.start); Piece { index, segment, from: session_start - segment.start, to: end.min(segment.end) - segment.start, session_start, } }) .collect() } /// Keyframes are anchored to each capture run, which may start after a gap. pub fn export_range(&self, start: Duration, end: Duration) -> (Duration, Duration) { let anchor = |time| { self.segments .iter() .rev() .find(|s| s.available && s.start <= time) .map_or(time, |segment| { segment.start.saturating_sub(frame_offset( segment.frame_start, segment.frames_per_second, )) }) }; let a = anchor(start); let b = anchor(end.saturating_sub(Duration::from_nanos(1))); let (from, _) = crate::library::export_range( start.saturating_sub(a), start.saturating_sub(a), Duration::from_secs(1), self.session.length.max(end + Duration::from_secs(1)), ); let (_, to) = crate::library::export_range( end.saturating_sub(b), end.saturating_sub(b), Duration::from_secs(1), self.session.length.max(end + Duration::from_secs(1)), ); let start = (a + from).min(self.session.length); let end = if self.session.active { b + to } else { (b + to).min(self.session.length) }; let pieces = self.covering(start, end); let start = pieces.first().map_or(start, |p| p.session_start); let end = if self.session.active { end } else { pieces.last().map_or(end, |p| p.segment.start + p.to) }; (start, end) } /// Split existing spans so deleted footage remains visible on the timeline. pub fn removed(&mut self, index: usize) { let segment = &mut self.segments[index]; segment.available = false; let (start, end) = (segment.start, segment.end); let mut spans = Vec::new(); for span in &self.session.spans { if span.kind != SpanKind::Footage || span.end <= start || span.start >= end { spans.push(*span); continue; } if span.start < start { spans.push(Span { end: start, ..*span }); } spans.push(Span { start: span.start.max(start), end: span.end.min(end), kind: SpanKind::Removed, }); if span.end > end { spans.push(Span { start: end, ..*span }); } } self.session.spans = spans; } pub fn update_run( &mut self, span_base: usize, footage_start: Option, footage_end: Duration, elapsed: Duration, ) { self.session.spans.truncate(span_base); if let Some(start) = footage_start { let previous = self.session.spans.last().map_or(Duration::ZERO, |s| s.end); if start > previous { self.session.spans.push(Span { start: previous, end: start, kind: SpanKind::Missing, }); } if footage_end > start { self.session.spans.push(Span { start, end: footage_end, kind: SpanKind::Footage, }); } } else { let start = self.session.spans.last().map_or(Duration::ZERO, |s| s.end); if elapsed > start { self.session.spans.push(Span { start, end: elapsed, kind: SpanKind::Missing, }); } } self.session.length = elapsed.max(footage_end); } } #[cfg(test)] mod tests { fn segment(start: u64, end: u64, available: bool) -> Segment { Segment { path: format!("segment-{start}.mkv").into(), start: Duration::from_secs(start), end: Duration::from_secs(end), complete: true, available, frame_start: start * 60, frames_per_second: 60, dropped_frames: 0, audio_start_samples: Vec::new(), } } fn timeline(segments: Vec) -> Metadata { Metadata { session: Session { id: 1, game: "Game".into(), started: UNIX_EPOCH, length: Duration::from_secs(10), active: false, spans: Vec::new(), bookmarks: Vec::new(), }, app_id: 1, segments, } } #[test] fn older_metadata_defaults_audio_clocks_without_losing_bookmarks() { let m = timeline(vec![segment(0, 2, true)]); let mut json = serde_json::to_value(m).unwrap(); json["segments"][0] .as_object_mut() .unwrap() .remove("audio_start_samples"); let restored: Metadata = serde_json::from_value(json).unwrap(); assert!(restored.segments[0].audio_start_samples.is_empty()); assert!(restored.segments[0].available); } #[test] fn export_keyframes_follow_run_anchor_and_outer_gaps_are_trimmed() { let mut metadata = timeline(vec![segment(2, 4, true), segment(4, 6, true)]); metadata.segments[0].frame_start = 0; metadata.segments[1].frame_start = 120; assert_eq!( metadata.export_range(Duration::from_millis(2400), Duration::from_millis(5100)), (Duration::from_secs(2), Duration::from_secs(6)) ); assert_eq!( metadata.export_range(Duration::ZERO, Duration::from_secs(10)), (Duration::from_secs(2), Duration::from_secs(6)) ); metadata.session.active = true; assert_eq!( metadata .export_range(Duration::ZERO, Duration::from_secs(9)) .1, Duration::from_secs(9) ); } #[test] fn removal_splits_footage_preserving_missing_spans_and_is_idempotent() { let mut metadata = timeline(vec![segment(2, 4, true)]); metadata.session.spans = vec![ Span { start: Duration::ZERO, end: Duration::from_secs(6), kind: SpanKind::Footage, }, Span { start: Duration::from_secs(6), end: Duration::from_secs(8), kind: SpanKind::Missing, }, ]; metadata.removed(0); assert_eq!(metadata.session.removed_time(), Duration::from_secs(2)); assert_eq!(metadata.session.missing_time(), Duration::from_secs(2)); assert!(!metadata.segments[0].available); let spans = metadata.session.spans.clone(); metadata.removed(0); assert_eq!(metadata.session.spans, spans); assert!(metadata.locate(Duration::from_secs(3)).is_none()); } #[test] fn times_map_to_segment_offsets_and_gaps_have_no_footage() { // 0-2 s, 2-4 s, a gap, 6-8 s, and an unavailable tail at 8-10 s. let metadata = timeline(vec![ segment(0, 2, true), segment(2, 4, true), segment(6, 8, true), segment(8, 10, false), ]); let ms = Duration::from_millis; let at = metadata.locate(ms(2500)).unwrap(); assert_eq!((at.index, at.offset), (1, ms(500))); assert_eq!(metadata.locate(ms(2000)).unwrap().index, 1); assert!(metadata.locate(ms(5000)).is_none()); assert!(metadata.locate(ms(9000)).is_none()); let pieces = metadata.covering(ms(1500), ms(7000)); let summary: Vec<_> = pieces .iter() .map(|piece| (piece.index, piece.from, piece.to, piece.session_start)) .collect(); assert_eq!( summary, [ (0, ms(1500), ms(2000), ms(1500)), (1, ms(0), ms(2000), ms(2000)), (2, ms(0), ms(1000), ms(6000)), ] ); } use super::*; #[test] fn offsets_use_frames_without_accumulating_container_rounding() { assert_eq!(segment_offset(0, 60), Duration::ZERO); assert_eq!(segment_offset(121, 60), Duration::from_secs(2)); assert_eq!(segment_offset(241, 60), Duration::from_secs(4)); assert_eq!(segment_offset(361, 60), Duration::from_secs(6)); assert_eq!(frame_offset(120, 60), Duration::from_secs(2)); assert_eq!(frame_offset(121, 60), Duration::from_nanos(2_016_666_666)); assert_eq!(frame_offset(216_000, 60), Duration::from_secs(3600)); } #[test] fn recovery_preserves_footage_and_a_wall_clock_gap() { let directory = tempfile::tempdir().unwrap(); let store = Store { root: directory.path().into(), }; let mut metadata = store.create(480, "Game".into()).unwrap(); metadata.update_run( 0, Some(Duration::from_secs(1)), Duration::from_secs(5), Duration::from_secs(5), ); let base = metadata.session.spans.len(); metadata.update_run(base, None, Duration::ZERO, Duration::from_secs(8)); assert_eq!(metadata.session.missing_time(), Duration::from_secs(4)); metadata.update_run( base, Some(Duration::from_secs(10)), Duration::from_secs(12), Duration::from_secs(12), ); assert_eq!(metadata.session.missing_time(), Duration::from_secs(6)); assert_eq!( metadata.session.spans.last().unwrap().kind, SpanKind::Footage ); } #[test] fn atomic_writes_leave_readers_a_complete_document() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("settings.json"); atomic_json(&path, &Settings::default()).unwrap(); std::thread::scope(|scope| { let reader = scope.spawn(|| { for _ in 0..100 { let bytes = fs::read(&path).unwrap(); serde_json::from_slice::(&bytes).unwrap(); } }); for rate in [30, 60, 120] { atomic_json( &path, &Settings { frames_per_second: rate, ..Settings::default() }, ) .unwrap(); } reader.join().unwrap(); }); } #[test] fn round_trip_atomic_replace_and_crash_recovery() { let root = std::env::temp_dir().join(format!( "clipbin-store-{}-{}", std::process::id(), SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() )); let store = Store { root }; let mut metadata = store.create(480, "Test game".into()).unwrap(); metadata.session.length = Duration::from_secs(7); metadata.segments.push(Segment { path: PathBuf::from("segment.mkv"), start: Duration::ZERO, end: Duration::from_secs(7), complete: false, available: true, frame_start: 0, frames_per_second: 60, dropped_frames: 0, audio_start_samples: Vec::new(), }); fs::write( store.directory(metadata.session.id).join("segment.mkv"), [0u8; 13], ) .unwrap(); store.save(&metadata).unwrap(); assert!( !store .directory(metadata.session.id) .join("session.json.tmp") .exists() ); assert_eq!(store.read().unwrap()[0].session, metadata.session); fs::write( store.directory(metadata.session.id).join("interrupted.tmp"), b"{partial", ) .unwrap(); store.recover().unwrap(); let library = store.library(100).unwrap(); assert!(!library.sessions[0].active); assert_eq!(library.sessions[0].length, Duration::from_secs(7)); assert_eq!(library.storage.unprotected_bytes, 13); assert!(store.read().unwrap()[0].segments[0].complete); fs::remove_dir_all(store.root).unwrap(); } }