Files
clipbin-desktop/crates/clipbin-core/src/store.rs
T

632 lines
22 KiB
Rust

//! 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<u64>,
}
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<Segment>,
}
const METADATA_FILE: &str = "session.json";
#[derive(Clone)]
pub struct Store {
pub root: PathBuf,
}
pub fn config_path() -> io::Result<PathBuf> {
Ok(dirs::config_dir()
.ok_or_else(|| io::Error::other("No configuration directory"))?
.join("clipbin/settings.json"))
}
pub fn load_settings() -> io::Result<Settings> {
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<Self> {
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<Metadata> {
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<Vec<Metadata>> {
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<Metadata> {
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<Library> {
self.library_with_protection(budget, &[])
}
pub fn library_with_protection(
&self,
budget: u64,
ranges: &[(u64, Duration, Duration)],
) -> io::Result<Library> {
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<Item = (usize, &Segment)> {
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<Located<'_>> {
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<Piece<'_>> {
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<Duration>,
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<Segment>) -> 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::<Settings>(&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();
}
}