//! Saved MP4s and their source ranges. Clips are never part of automatic cleanup. use crate::{ library::Clip, store::{Store, atomic_json}, }; use serde::{Deserialize, Serialize}; use std::{fs, io, path::PathBuf, time::Duration}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SavedClip { pub clip: Clip, pub source_session: u64, pub start: Duration, pub end: Duration, pub separate_audio: bool, } /// Pending and failed exports retain their source leases across app restarts. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PendingExport { pub session: u64, pub start: Duration, pub end: Duration, pub separate: bool, pub failed: bool, } impl Store { pub fn pending_exports(&self) -> io::Result> { match fs::read(self.clips_directory().join("export-requests.json")) { Ok(bytes) => serde_json::from_slice(&bytes).map_err(io::Error::other), Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()), Err(e) => Err(e), } } pub fn save_pending_exports(&self, exports: &[PendingExport]) -> io::Result<()> { atomic_json( &self.clips_directory().join("export-requests.json"), &exports, ) } pub fn clips_directory(&self) -> PathBuf { self.root.parent().unwrap_or(&self.root).join("clips") } pub fn clip_path(&self, id: u64) -> PathBuf { self.clips_directory().join(format!("{id}.mp4")) } pub fn save_clip(&self, clip: &SavedClip) -> io::Result<()> { atomic_json( &self .clips_directory() .join(format!("{}.json", clip.clip.id)), clip, ) } pub fn clips(&self) -> io::Result> { let entries = match fs::read_dir(self.clips_directory()) { Ok(e) => e, Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(vec![]), Err(e) => return Err(e), }; let mut clips = vec![]; for entry in entries { let path = entry?.path(); if path.extension().is_some_and(|e| e == "json") && path .file_stem() .and_then(|s| s.to_str()) .is_some_and(|s| s.parse::().is_ok()) { let saved: SavedClip = serde_json::from_slice(&fs::read(path)?).map_err(io::Error::other)?; if self.clip_path(saved.clip.id).is_file() { clips.push(saved); } } } clips.sort_by_key(|s| std::cmp::Reverse(s.clip.created)); Ok(clips) } pub fn delete_clip(&self, id: u64) -> io::Result<()> { fs::remove_file(self.clip_path(id))?; fs::remove_file(self.clips_directory().join(format!("{id}.json"))) } } /// Launch an OS association without shell interpolation, off the UI thread. pub fn open(path: PathBuf) -> io::Result<()> { #[cfg(target_os = "linux")] let status = std::process::Command::new("xdg-open").arg(path).status()?; #[cfg(windows)] let status = std::process::Command::new("explorer.exe") .arg(path) .status()?; #[cfg(not(any(target_os = "linux", windows)))] return Err(io::Error::other( "Opening files is unsupported on this platform", )); #[cfg(any(target_os = "linux", windows))] if status.success() { Ok(()) } else { Err(io::Error::other(format!( "File opener exited with {status}" ))) } } #[cfg(test)] mod tests { use super::*; #[test] fn pending_and_failed_export_leases_round_trip_without_becoming_clips() { let temp = tempfile::tempdir().unwrap(); let store = Store { root: temp.path().join("sessions"), }; let request = PendingExport { session: 42, start: Duration::from_secs(3), end: Duration::from_secs(5), separate: false, failed: true, }; store.save_pending_exports(&[request]).unwrap(); let requests = store.pending_exports().unwrap(); assert_eq!(requests.len(), 1); assert!(requests[0].failed); assert_eq!(requests[0].session, 42); assert!(store.clips().unwrap().is_empty()); store.save_pending_exports(&[]).unwrap(); assert!(store.pending_exports().unwrap().is_empty()); } #[test] fn clips_require_a_published_mp4_and_survive_session_recovery() { let temp = tempfile::tempdir().unwrap(); let store = Store { root: temp.path().join("sessions"), }; let saved = SavedClip { clip: Clip { id: 1, game: "Test".into(), created: std::time::SystemTime::UNIX_EPOCH, length: Duration::from_secs(2), size_bytes: 4, }, source_session: 42, start: Duration::from_secs(3), end: Duration::from_secs(5), separate_audio: false, }; store.save_clip(&saved).unwrap(); assert!(store.clips().unwrap().is_empty()); fs::write(store.clip_path(1), b"test").unwrap(); store.recover().unwrap(); assert_eq!(store.clips().unwrap()[0].source_session, 42); let library = store.library(1).unwrap(); assert_eq!(library.storage.clip_bytes, 4); assert_eq!(library.storage.unprotected_bytes, 0); store.delete_clip(1).unwrap(); assert!(store.clips().unwrap().is_empty()); } }