163 lines
5.5 KiB
Rust
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());
|
|
}
|
|
}
|