feat: export clips, protect bookmarks and clean session storage
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user