feat: automatically record Steam game sessions
Detect games with native process exit watchers, own OBS on a background worker, and persist settings and recoverable session timelines. Place segments by split frame counts, recover capture with honest gaps, and finalise on game exit or explicit Quit. Keep the current recording alive after GPUI closes its last window, then exit when the game ends. Add an end-to-end rig covering recording, recovery, exact packet-count offsets, disk library reload and clean Quit.
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
//! 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, 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,
|
||||
}
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
#[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("session.json"),
|
||||
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("session.json");
|
||||
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(())
|
||||
}
|
||||
pub fn library(&self, budget: u64) -> io::Result<Library> {
|
||||
let mut library = Library::empty(budget);
|
||||
for metadata in self.read()? {
|
||||
for segment in &metadata.segments {
|
||||
let bytes = fs::metadata(self.directory(metadata.session.id).join(&segment.path))
|
||||
.map_or(0, |f| f.len());
|
||||
if metadata.session.bookmarks.iter().any(|b| {
|
||||
let (start, end) = b.range(metadata.session.length);
|
||||
b.protected && start < segment.end && end > segment.start
|
||||
}) {
|
||||
library.storage.protected_bytes += bytes;
|
||||
} else {
|
||||
library.storage.unprotected_bytes += bytes;
|
||||
}
|
||||
}
|
||||
library.sessions.push(metadata.session);
|
||||
}
|
||||
library
|
||||
.sessions
|
||||
.sort_by_key(|s| std::cmp::Reverse(s.started));
|
||||
Ok(library)
|
||||
}
|
||||
}
|
||||
|
||||
impl Metadata {
|
||||
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 {
|
||||
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,
|
||||
});
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user