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

163 lines
5.5 KiB
Rust

//! 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<Vec<PendingExport>> {
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<Vec<SavedClip>> {
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::<u64>().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());
}
}