718 lines
21 KiB
Rust
718 lines
21 KiB
Rust
//! Application configuration.
|
|
//!
|
|
//! Configuration can be loaded from:
|
|
//! 1. TOML file (config.toml)
|
|
//! 2. Environment variables (prefixed with IMMICH_)
|
|
//! 3. Programmatic overrides
|
|
//!
|
|
//! ## Step Configuration Pattern
|
|
//!
|
|
//! Each pipeline step has its own optional sub-config with an `enabled` flag:
|
|
//! ```ignore
|
|
//! processing:
|
|
//! face_resolution:
|
|
//! enabled: true
|
|
//! min_size: 80
|
|
//! brightness:
|
|
//! enabled: false
|
|
//! min_brightness: 0.1
|
|
//! max_brightness: 0.95
|
|
//! ```
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
|
|
use crate::error::{Error, Result};
|
|
|
|
/// Main configuration struct.
|
|
///
|
|
/// This is the single source of truth for all configuration.
|
|
/// Pass this explicitly to functions that need it - no globals.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct Config {
|
|
/// Immich API configuration.
|
|
pub api: ApiConfig,
|
|
|
|
/// Face processing parameters.
|
|
pub processing: ProcessingConfig,
|
|
|
|
/// Video output settings.
|
|
pub video: VideoConfig,
|
|
|
|
/// Output directory for processed images and video.
|
|
pub output_dir: PathBuf,
|
|
}
|
|
|
|
impl Default for Config {
|
|
fn default() -> Self {
|
|
Self {
|
|
api: ApiConfig::default(),
|
|
processing: ProcessingConfig::default(),
|
|
video: VideoConfig::default(),
|
|
output_dir: PathBuf::from("output"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Immich API connection settings.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ApiConfig {
|
|
/// API key for authentication.
|
|
pub api_key: String,
|
|
|
|
/// Base URL of the Immich instance (e.g., "http://192.168.1.94:2283/api").
|
|
pub base_url: String,
|
|
|
|
/// Request timeout in seconds.
|
|
#[serde(default = "default_timeout")]
|
|
pub timeout_secs: u64,
|
|
}
|
|
|
|
fn default_timeout() -> u64 {
|
|
30
|
|
}
|
|
|
|
impl Default for ApiConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
api_key: String::new(),
|
|
base_url: String::new(),
|
|
timeout_secs: default_timeout(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Step Configurations
|
|
// ============================================================================
|
|
|
|
/// Face resolution validation configuration.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct FaceResolutionConfig {
|
|
/// Whether face resolution validation is enabled.
|
|
pub enabled: bool,
|
|
|
|
/// Minimum face width/height in pixels.
|
|
/// Faces smaller than this will be skipped.
|
|
pub min_size: u32,
|
|
}
|
|
|
|
impl Default for FaceResolutionConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
min_size: 80,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl FaceResolutionConfig {
|
|
/// Validate the configuration values.
|
|
pub fn validate(&self) -> Result<()> {
|
|
if self.enabled && self.min_size == 0 {
|
|
return Err(Error::Config(
|
|
"Face resolution min_size must be greater than 0".to_string(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Brightness validation configuration.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct BrightnessConfig {
|
|
/// Whether brightness validation is enabled.
|
|
pub enabled: bool,
|
|
|
|
/// Minimum acceptable brightness (0.0-1.0).
|
|
/// Images darker than this will be skipped.
|
|
pub min_brightness: f32,
|
|
|
|
/// Maximum acceptable brightness (0.0-1.0).
|
|
/// Images brighter than this will be skipped.
|
|
pub max_brightness: f32,
|
|
}
|
|
|
|
impl Default for BrightnessConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
min_brightness: 0.1,
|
|
max_brightness: 0.95,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl BrightnessConfig {
|
|
/// Validate the configuration values.
|
|
pub fn validate(&self) -> Result<()> {
|
|
if !self.enabled {
|
|
return Ok(());
|
|
}
|
|
if self.min_brightness < 0.0 || self.min_brightness > 1.0 {
|
|
return Err(Error::Config(
|
|
"Brightness min_brightness must be between 0.0 and 1.0".to_string(),
|
|
));
|
|
}
|
|
if self.max_brightness < 0.0 || self.max_brightness > 1.0 {
|
|
return Err(Error::Config(
|
|
"Brightness max_brightness must be between 0.0 and 1.0".to_string(),
|
|
));
|
|
}
|
|
if self.min_brightness >= self.max_brightness {
|
|
return Err(Error::Config(
|
|
"Brightness min_brightness must be less than max_brightness".to_string(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Output/resize configuration.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct OutputConfig {
|
|
/// Output image size (width and height in pixels).
|
|
pub size: u32,
|
|
|
|
/// Keep intermediate images (original, cropped) for inspection.
|
|
pub keep_intermediates: bool,
|
|
}
|
|
|
|
impl Default for OutputConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
size: 512,
|
|
keep_intermediates: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl OutputConfig {
|
|
/// Validate the configuration values.
|
|
pub fn validate(&self) -> Result<()> {
|
|
if self.size < 64 {
|
|
return Err(Error::Config(
|
|
"Output size must be at least 64 pixels".to_string(),
|
|
));
|
|
}
|
|
if self.size > 4096 {
|
|
return Err(Error::Config(
|
|
"Output size must be at most 4096 pixels".to_string(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Head pose estimation configuration.
|
|
///
|
|
/// Uses DMHead ONNX model to estimate head pose angles and filter
|
|
/// non-front-facing faces.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct HeadPoseConfig {
|
|
/// Whether head pose filtering is enabled.
|
|
pub enabled: bool,
|
|
|
|
/// Maximum allowed yaw angle (left/right turn) in degrees.
|
|
pub max_yaw: f32,
|
|
|
|
/// Maximum allowed pitch angle (up/down tilt) in degrees.
|
|
pub max_pitch: f32,
|
|
|
|
/// Maximum allowed roll angle (head tilt) in degrees.
|
|
pub max_roll: f32,
|
|
}
|
|
|
|
impl Default for HeadPoseConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
max_yaw: 35.0,
|
|
max_pitch: 35.0,
|
|
max_roll: 25.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl HeadPoseConfig {
|
|
/// Validate the configuration values.
|
|
pub fn validate(&self) -> Result<()> {
|
|
if self.enabled {
|
|
if self.max_yaw < 0.0 || self.max_yaw > 90.0 {
|
|
return Err(Error::Config(
|
|
"Head pose max_yaw must be between 0 and 90 degrees".to_string(),
|
|
));
|
|
}
|
|
if self.max_pitch < 0.0 || self.max_pitch > 90.0 {
|
|
return Err(Error::Config(
|
|
"Head pose max_pitch must be between 0 and 90 degrees".to_string(),
|
|
));
|
|
}
|
|
if self.max_roll < 0.0 || self.max_roll > 90.0 {
|
|
return Err(Error::Config(
|
|
"Head pose max_roll must be between 0 and 90 degrees".to_string(),
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Eye filter configuration for blink detection.
|
|
///
|
|
/// Uses Eye Aspect Ratio (EAR) computed from facial landmarks to detect
|
|
/// closed eyes.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct EyeFilterConfig {
|
|
/// Whether eye filtering is enabled.
|
|
pub enabled: bool,
|
|
|
|
/// Minimum Eye Aspect Ratio (EAR) threshold.
|
|
/// Eyes with EAR below this are considered closed.
|
|
pub min_ear: f32,
|
|
}
|
|
|
|
impl Default for EyeFilterConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
min_ear: 0.2,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl EyeFilterConfig {
|
|
/// Validate the configuration values.
|
|
pub fn validate(&self) -> Result<()> {
|
|
if self.enabled {
|
|
if self.min_ear < 0.0 || self.min_ear > 0.5 {
|
|
return Err(Error::Config(
|
|
"Eye filter min_ear must be between 0.0 and 0.5".to_string(),
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Face alignment configuration for landmark-based alignment.
|
|
///
|
|
/// Aligns faces based on eye positions detected from facial landmarks,
|
|
/// ensuring consistent eye placement across all images for smoother timelapses.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AlignmentConfig {
|
|
/// Whether face alignment is enabled.
|
|
pub enabled: bool,
|
|
|
|
/// Target Y position for eyes as percentage from top (0.0-1.0).
|
|
/// Default 0.35 places eyes at 35% from the top.
|
|
pub eye_y_position: f32,
|
|
|
|
/// Target inter-eye distance as percentage of output width (0.0-1.0).
|
|
/// Default 0.3 makes the distance between eye centers 30% of image width.
|
|
pub inter_eye_distance: f32,
|
|
}
|
|
|
|
impl Default for AlignmentConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
eye_y_position: 0.35,
|
|
inter_eye_distance: 0.30,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AlignmentConfig {
|
|
/// Validate the configuration values.
|
|
pub fn validate(&self) -> Result<()> {
|
|
if self.enabled {
|
|
if self.eye_y_position < 0.2 || self.eye_y_position > 0.5 {
|
|
return Err(Error::Config(
|
|
"Alignment eye_y_position must be between 0.2 and 0.5".to_string(),
|
|
));
|
|
}
|
|
if self.inter_eye_distance < 0.2 || self.inter_eye_distance > 0.5 {
|
|
return Err(Error::Config(
|
|
"Alignment inter_eye_distance must be between 0.2 and 0.5".to_string(),
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Main Processing Configuration
|
|
// ============================================================================
|
|
|
|
/// Face processing parameters.
|
|
///
|
|
/// Each pipeline step has its own sub-configuration with an `enabled` flag.
|
|
/// This allows fine-grained control over which steps run and their parameters.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ProcessingConfig {
|
|
/// Number of parallel workers for processing.
|
|
pub max_workers: usize,
|
|
|
|
/// Face resolution validation settings.
|
|
#[serde(default)]
|
|
pub face_resolution: FaceResolutionConfig,
|
|
|
|
/// Brightness validation settings.
|
|
#[serde(default)]
|
|
pub brightness: BrightnessConfig,
|
|
|
|
/// Head pose estimation settings.
|
|
#[serde(default)]
|
|
pub head_pose: HeadPoseConfig,
|
|
|
|
/// Eye filter settings (blink detection).
|
|
#[serde(default)]
|
|
pub eye_filter: EyeFilterConfig,
|
|
|
|
/// Output image settings.
|
|
#[serde(default)]
|
|
pub output: OutputConfig,
|
|
|
|
/// Face alignment settings (landmark-based).
|
|
#[serde(default)]
|
|
pub alignment: AlignmentConfig,
|
|
}
|
|
|
|
impl Default for ProcessingConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
max_workers: num_cpus(),
|
|
face_resolution: FaceResolutionConfig::default(),
|
|
brightness: BrightnessConfig::default(),
|
|
head_pose: HeadPoseConfig::default(),
|
|
eye_filter: EyeFilterConfig::default(),
|
|
output: OutputConfig::default(),
|
|
alignment: AlignmentConfig::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ProcessingConfig {
|
|
/// Validate all step configurations.
|
|
pub fn validate(&self) -> Result<()> {
|
|
self.face_resolution.validate()?;
|
|
self.brightness.validate()?;
|
|
self.head_pose.validate()?;
|
|
self.eye_filter.validate()?;
|
|
self.output.validate()?;
|
|
self.alignment.validate()?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn num_cpus() -> usize {
|
|
std::thread::available_parallelism()
|
|
.map(|p| p.get())
|
|
.unwrap_or(4)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Video Configuration
|
|
// ============================================================================
|
|
|
|
/// Video compilation settings.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct VideoConfig {
|
|
/// Whether to compile video after processing.
|
|
pub enabled: bool,
|
|
|
|
/// Output video framerate.
|
|
pub framerate: u32,
|
|
|
|
/// Video codec (e.g., "libx264").
|
|
pub codec: String,
|
|
|
|
/// Constant Rate Factor for quality (0-51, lower = better, 18-28 recommended).
|
|
pub crf: u32,
|
|
}
|
|
|
|
impl Default for VideoConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
framerate: 15,
|
|
codec: "libx264".to_string(),
|
|
crf: 23,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl VideoConfig {
|
|
/// Validate the configuration values.
|
|
pub fn validate(&self) -> Result<()> {
|
|
if self.framerate == 0 {
|
|
return Err(Error::Config(
|
|
"Video framerate must be greater than 0".to_string(),
|
|
));
|
|
}
|
|
if self.framerate > 120 {
|
|
return Err(Error::Config(
|
|
"Video framerate must be at most 120".to_string(),
|
|
));
|
|
}
|
|
if self.crf > 51 {
|
|
return Err(Error::Config(
|
|
"Video CRF must be between 0 and 51".to_string(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Persistence
|
|
// ============================================================================
|
|
|
|
/// Persistable configuration (excludes sensitive API credentials).
|
|
///
|
|
/// This struct contains only the settings that can safely be written to disk.
|
|
/// API credentials should always come from environment variables.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PersistableConfig {
|
|
pub processing: ProcessingConfig,
|
|
pub video: VideoConfig,
|
|
}
|
|
|
|
impl From<&Config> for PersistableConfig {
|
|
fn from(config: &Config) -> Self {
|
|
Self {
|
|
processing: config.processing.clone(),
|
|
video: config.video.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Config Loading and Validation
|
|
// ============================================================================
|
|
|
|
impl Config {
|
|
/// Load configuration from a TOML file.
|
|
pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
|
|
let content = std::fs::read_to_string(path)?;
|
|
let config: Config =
|
|
toml::from_str(&content).map_err(|e| Error::Config(e.to_string()))?;
|
|
Ok(config)
|
|
}
|
|
|
|
/// Load configuration from environment variables.
|
|
///
|
|
/// Recognized variables:
|
|
/// - IMMICH_API_KEY - API key for Immich
|
|
/// - IMMICH_BASE_URL - Base URL of Immich instance
|
|
/// - OUTPUT_DIR - Output directory for processed images/video
|
|
/// - RESIZE_SIZE - Output image size in pixels
|
|
/// - FACE_RESOLUTION_THRESHOLD - Minimum face size in pixels
|
|
/// - MAX_WORKERS - Number of parallel processing workers
|
|
/// - VIDEO_FRAMERATE - Output video framerate
|
|
/// - VIDEO_ENABLED - Whether to compile video (true/false)
|
|
/// - KEEP_INTERMEDIATES - Keep debug images (true/false)
|
|
pub fn from_env() -> Self {
|
|
Config::default().with_env()
|
|
}
|
|
|
|
/// Merge environment variables into an existing config.
|
|
pub fn with_env(mut self) -> Self {
|
|
// API settings
|
|
if let Ok(key) = std::env::var("IMMICH_API_KEY") {
|
|
self.api.api_key = key;
|
|
}
|
|
if let Ok(url) = std::env::var("IMMICH_BASE_URL") {
|
|
self.api.base_url = url;
|
|
}
|
|
|
|
// Output directory
|
|
if let Ok(dir) = std::env::var("OUTPUT_DIR") {
|
|
self.output_dir = PathBuf::from(dir);
|
|
}
|
|
|
|
// Processing settings
|
|
if let Ok(val) = std::env::var("RESIZE_SIZE") {
|
|
if let Ok(size) = val.parse() {
|
|
self.processing.output.size = size;
|
|
}
|
|
}
|
|
if let Ok(val) = std::env::var("FACE_RESOLUTION_THRESHOLD") {
|
|
if let Ok(threshold) = val.parse() {
|
|
self.processing.face_resolution.min_size = threshold;
|
|
}
|
|
}
|
|
if let Ok(val) = std::env::var("MAX_WORKERS") {
|
|
if let Ok(workers) = val.parse() {
|
|
self.processing.max_workers = workers;
|
|
}
|
|
}
|
|
if let Ok(val) = std::env::var("KEEP_INTERMEDIATES") {
|
|
self.processing.output.keep_intermediates =
|
|
val.eq_ignore_ascii_case("true") || val == "1";
|
|
}
|
|
|
|
// Video settings
|
|
if let Ok(val) = std::env::var("VIDEO_FRAMERATE") {
|
|
if let Ok(fps) = val.parse() {
|
|
self.video.framerate = fps;
|
|
}
|
|
}
|
|
if let Ok(val) = std::env::var("VIDEO_ENABLED") {
|
|
self.video.enabled = val.eq_ignore_ascii_case("true") || val == "1";
|
|
}
|
|
if let Ok(val) = std::env::var("VIDEO_CRF") {
|
|
if let Ok(crf) = val.parse() {
|
|
self.video.crf = crf;
|
|
}
|
|
}
|
|
|
|
self
|
|
}
|
|
|
|
/// Validate the entire configuration.
|
|
pub fn validate(&self) -> Result<()> {
|
|
// API validation
|
|
if self.api.api_key.is_empty() {
|
|
return Err(Error::Config("API key is required".to_string()));
|
|
}
|
|
if self.api.base_url.is_empty() {
|
|
return Err(Error::Config("Base URL is required".to_string()));
|
|
}
|
|
|
|
// Delegate to sub-config validation
|
|
self.processing.validate()?;
|
|
self.video.validate()?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Save processing and video configuration to a TOML file.
|
|
///
|
|
/// Only saves `processing` and `video` sections - API credentials are
|
|
/// intentionally excluded as they should come from environment variables.
|
|
pub fn save_to_file(&self, path: impl AsRef<std::path::Path>) -> Result<()> {
|
|
let persistable = PersistableConfig::from(self);
|
|
let content = toml::to_string_pretty(&persistable)
|
|
.map_err(|e| Error::Config(format!("Failed to serialize config: {}", e)))?;
|
|
std::fs::write(path, content)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tests
|
|
// ============================================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_default_config() {
|
|
let config = Config::default();
|
|
assert_eq!(config.processing.output.size, 512);
|
|
assert_eq!(config.processing.face_resolution.min_size, 80);
|
|
assert_eq!(config.video.framerate, 15);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validation_missing_api_key() {
|
|
let config = Config::default();
|
|
let result = config.validate();
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_brightness_validation() {
|
|
let mut config = BrightnessConfig::default();
|
|
config.enabled = true;
|
|
|
|
// Valid config
|
|
config.min_brightness = 0.1;
|
|
config.max_brightness = 0.9;
|
|
assert!(config.validate().is_ok());
|
|
|
|
// Invalid: min >= max
|
|
config.min_brightness = 0.9;
|
|
config.max_brightness = 0.1;
|
|
assert!(config.validate().is_err());
|
|
|
|
// Invalid: out of range
|
|
config.min_brightness = -0.1;
|
|
config.max_brightness = 0.9;
|
|
assert!(config.validate().is_err());
|
|
|
|
// Disabled config skips validation
|
|
config.enabled = false;
|
|
assert!(config.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_video_validation() {
|
|
let mut config = VideoConfig::default();
|
|
|
|
// Valid
|
|
assert!(config.validate().is_ok());
|
|
|
|
// Invalid CRF
|
|
config.crf = 52;
|
|
assert!(config.validate().is_err());
|
|
|
|
// Invalid framerate
|
|
config.crf = 23;
|
|
config.framerate = 0;
|
|
assert!(config.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_output_validation() {
|
|
let mut config = OutputConfig::default();
|
|
|
|
// Valid
|
|
assert!(config.validate().is_ok());
|
|
|
|
// Too small
|
|
config.size = 32;
|
|
assert!(config.validate().is_err());
|
|
|
|
// Too large
|
|
config.size = 8192;
|
|
assert!(config.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_save_and_load_config() {
|
|
// Create a config with custom values
|
|
let mut config = Config::default();
|
|
config.processing.output.size = 256;
|
|
config.processing.face_resolution.min_size = 100;
|
|
config.video.framerate = 30;
|
|
config.video.crf = 18;
|
|
|
|
// Save to a temp file
|
|
let temp_dir = std::env::temp_dir();
|
|
let temp_path = temp_dir.join("test_config_new.toml");
|
|
|
|
config.save_to_file(&temp_path).expect("Failed to save config");
|
|
|
|
// Verify file was created and contains expected content
|
|
let content = std::fs::read_to_string(&temp_path).expect("Failed to read config");
|
|
assert!(content.contains("size = 256"));
|
|
assert!(content.contains("min_size = 100"));
|
|
assert!(content.contains("framerate = 30"));
|
|
assert!(content.contains("crf = 18"));
|
|
|
|
// Verify API credentials are NOT in the file
|
|
assert!(!content.contains("api_key"));
|
|
assert!(!content.contains("base_url"));
|
|
|
|
// Clean up
|
|
let _ = std::fs::remove_file(&temp_path);
|
|
}
|
|
}
|