immich-automated-selfie-tim.../src/web/state.rs
2026-02-12 20:20:12 +01:00

322 lines
10 KiB
Rust

//! Application state for the web server.
use crate::config::Config;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, RwLock as StdRwLock};
use tokio::sync::{broadcast, RwLock};
use tokio_util::sync::CancellationToken;
/// Job status.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JobStatus {
Idle,
Running,
Cancelling,
CompilingVideo,
Completed,
Cancelled,
Error(String),
}
impl JobStatus {
/// Returns true if this is a terminal status (job has finished).
/// Terminal statuses should not be overwritten by non-terminal statuses.
pub fn is_terminal(&self) -> bool {
matches!(
self,
JobStatus::Completed | JobStatus::Cancelled | JobStatus::Error(_)
)
}
/// Get the status as a string identifier for API responses.
pub fn as_str(&self) -> &str {
match self {
JobStatus::Idle => "idle",
JobStatus::Running => "running",
JobStatus::Cancelling => "cancelling",
JobStatus::CompilingVideo => "compiling_video",
JobStatus::Completed => "completed",
JobStatus::Cancelled => "cancelled",
JobStatus::Error(_) => "error",
}
}
}
impl std::fmt::Display for JobStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
/// Detailed statistics for skipped images.
///
/// Uses a HashMap to support dynamic skip reasons from pipeline steps.
#[derive(Debug, Clone, Default)]
pub struct SkipStats {
/// Map of skip reason ID to count.
counts: HashMap<String, u32>,
}
impl SkipStats {
/// Create new empty skip stats.
pub fn new() -> Self {
Self::default()
}
/// Get the count for a specific reason.
pub fn get(&self, reason: &str) -> u32 {
self.counts.get(reason).copied().unwrap_or(0)
}
/// Set the count for a specific reason.
pub fn set(&mut self, reason: impl Into<String>, count: u32) {
self.counts.insert(reason.into(), count);
}
/// Get all counts as a reference to the underlying HashMap.
pub fn counts(&self) -> &HashMap<String, u32> {
&self.counts
}
/// Total number of skipped images.
pub fn total(&self) -> u32 {
self.counts.values().sum()
}
}
/// Atomic version of SkipStats for thread-safe concurrent updates.
///
/// Uses a HashMap with atomic counters to support dynamic skip reasons
/// from pipeline steps. New reasons are added on first increment.
#[derive(Debug, Default)]
pub struct AtomicSkipStats {
/// Map of skip reason to atomic counter.
/// Uses std::sync::RwLock for synchronous access (required for HashMap mutations).
counts: StdRwLock<HashMap<String, Arc<AtomicU32>>>,
}
impl AtomicSkipStats {
/// Create a new AtomicSkipStats with no counters.
pub fn new() -> Self {
Self::default()
}
/// Increment a counter for the given reason string.
///
/// If the reason doesn't exist yet, it's created with an initial count of 1.
/// Returns the new count value.
pub fn increment(&self, reason: &str) -> u32 {
// First, try to get existing counter with read lock
{
let counts = self.counts.read().unwrap();
if let Some(counter) = counts.get(reason) {
return counter.fetch_add(1, Ordering::SeqCst) + 1;
}
}
// Counter doesn't exist, need write lock to create it
let mut counts = self.counts.write().unwrap();
// Double-check after acquiring write lock (another thread may have created it)
if let Some(counter) = counts.get(reason) {
return counter.fetch_add(1, Ordering::SeqCst) + 1;
}
// Create new counter starting at 1
let counter = Arc::new(AtomicU32::new(1));
counts.insert(reason.to_string(), counter);
1
}
/// Get a snapshot of the current stats as a non-atomic SkipStats.
pub fn snapshot(&self) -> SkipStats {
let counts = self.counts.read().unwrap();
let mut stats = SkipStats::new();
for (reason, counter) in counts.iter() {
stats.set(reason.clone(), counter.load(Ordering::SeqCst));
}
stats
}
/// Reset all counters to zero.
pub fn reset(&self) {
let mut counts = self.counts.write().unwrap();
counts.clear();
}
}
/// Progress information for a running job.
#[derive(Debug, Clone)]
pub struct Progress {
pub status: JobStatus,
pub completed: u32,
pub total: u32,
pub message: Option<String>,
pub skip_stats: SkipStats,
/// The person being processed (for display when resuming)
pub person_id: Option<String>,
pub person_name: Option<String>,
}
impl Default for Progress {
fn default() -> Self {
Self {
status: JobStatus::Idle,
completed: 0,
total: 0,
message: None,
skip_stats: SkipStats::default(),
person_id: None,
person_name: None,
}
}
}
/// Shared application state.
#[derive(Clone)]
pub struct AppState {
/// Current configuration.
pub config: Arc<RwLock<Config>>,
/// Current job progress.
pub progress: Arc<RwLock<Progress>>,
/// Channel for broadcasting progress updates.
pub progress_tx: broadcast::Sender<Progress>,
/// Cancellation token for the current job.
pub cancel_token: Arc<RwLock<Option<CancellationToken>>>,
}
impl AppState {
pub fn new(config: Config) -> Self {
let (progress_tx, _) = broadcast::channel(100);
Self {
config: Arc::new(RwLock::new(config)),
progress: Arc::new(RwLock::new(Progress::default())),
progress_tx,
cancel_token: Arc::new(RwLock::new(None)),
}
}
/// Update progress and broadcast to all listeners.
///
/// Note: This method prevents non-terminal statuses (Running, CompilingVideo)
/// from overwriting terminal statuses (Completed, Cancelled, Error). This
/// avoids race conditions where fire-and-forget progress updates arrive after
/// the job has finished.
pub async fn update_progress(&self, progress: Progress) {
let mut current = self.progress.write().await;
// Don't allow non-terminal statuses to overwrite terminal statuses.
// This prevents race conditions from fire-and-forget progress callbacks.
if current.status.is_terminal() && !progress.status.is_terminal() {
tracing::debug!(
"Ignoring progress update: current status {:?} is terminal, incoming {:?} is not",
current.status,
progress.status
);
return;
}
*current = progress.clone();
let _ = self.progress_tx.send(progress);
}
/// Reset progress for a new job, bypassing the terminal state check.
///
/// Use this when starting a new job to clear any previous terminal state
/// (Completed, Cancelled, Error) that would otherwise block progress updates.
pub async fn reset_progress(&self, progress: Progress) {
let mut current = self.progress.write().await;
*current = progress.clone();
let _ = self.progress_tx.send(progress);
}
/// Request cancellation of the current job.
pub async fn request_cancel(&self) -> bool {
let cancel_token = self.cancel_token.read().await;
if let Some(token) = cancel_token.as_ref() {
token.cancel();
// Update progress to show cancelling state immediately.
// Only change to Cancelling if in an active (non-terminal) state.
let mut progress = self.progress.write().await;
if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo
{
progress.status = JobStatus::Cancelling;
progress.message = Some("Cancelling...".to_string());
}
true
} else {
false
}
}
/// Create a new cancellation token for a job.
pub async fn create_cancel_token(&self) -> CancellationToken {
let token = CancellationToken::new();
*self.cancel_token.write().await = Some(token.clone());
token
}
/// Clear the cancellation token when job completes.
pub async fn clear_cancel_token(&self) {
*self.cancel_token.write().await = None;
}
/// Check if a job is currently running and return an error if so.
///
/// Use this at the start of handlers that cannot run while a job is in progress.
pub async fn ensure_no_job_running(&self) -> Result<(), JobRunningError> {
let progress = self.progress.read().await;
if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo {
Err(JobRunningError)
} else {
Ok(())
}
}
}
/// Error returned when an operation cannot proceed because a job is running.
#[derive(Debug, Clone, Copy)]
pub struct JobRunningError;
impl std::fmt::Display for JobRunningError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Cannot perform this operation while a job is running")
}
}
impl std::error::Error for JobRunningError {}
/// Validate that a path component (folder name or filename) is safe.
///
/// Returns an error if the path contains traversal sequences or separators.
pub fn validate_path_component(
name: &str,
component_type: &str,
) -> Result<(), PathValidationError> {
if name.contains("..") || name.contains('/') || name.contains('\\') {
Err(PathValidationError {
component_type: component_type.to_string(),
})
} else {
Ok(())
}
}
/// Error returned when a path component fails validation.
#[derive(Debug, Clone)]
pub struct PathValidationError {
pub component_type: String,
}
impl std::fmt::Display for PathValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Invalid {}", self.component_type)
}
}
impl std::error::Error for PathValidationError {}