diff --git a/Cargo.toml b/Cargo.toml index b900d73..bfb8f5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,9 +11,9 @@ repository = "https://github.com/ArnaudCrl/immich-automated-selfie-timelapse" tokio = { version = "1", features = ["full"] } # Web framework -axum = { version = "0.7", features = ["ws"] } -tower = "0.4" -tower-http = { version = "0.5", features = ["fs", "cors"] } +axum = { version = "0.8", features = ["ws"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["fs", "cors"] } # HTTP client reqwest = { version = "0.12", features = ["json", "stream"] } diff --git a/frontend/src/lib/components/PeopleSelector.svelte b/frontend/src/lib/components/PeopleSelector.svelte index dbbbbaf..2ef2255 100644 --- a/frontend/src/lib/components/PeopleSelector.svelte +++ b/frontend/src/lib/components/PeopleSelector.svelte @@ -70,7 +70,12 @@ {disabled} >
- {(person.name || 'U').charAt(0).toUpperCase()} + {person.name e.target.style.display = 'none'} + /> + {(person.name || 'U').charAt(0).toUpperCase()}
{person.name || 'Unnamed'} @@ -162,6 +167,18 @@ font-weight: 600; font-size: 1.25rem; color: #fff; + overflow: hidden; + position: relative; + } + + .avatar img { + width: 100%; + height: 100%; + object-fit: cover; + position: absolute; + top: 0; + left: 0; + z-index: 1; } .name { diff --git a/frontend/src/lib/components/ProcessingControls.svelte b/frontend/src/lib/components/ProcessingControls.svelte index 702721e..9739124 100644 --- a/frontend/src/lib/components/ProcessingControls.svelte +++ b/frontend/src/lib/components/ProcessingControls.svelte @@ -7,7 +7,9 @@ let dateTo = $state(''); let pollInterval = $state(null); - let isRunning = $derived(jobStatus === 'running' || jobStatus === 'compiling_video'); + let isRunning = $derived( + jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling' + ); async function startProcessing() { try { diff --git a/frontend/src/lib/components/ProgressDisplay.svelte b/frontend/src/lib/components/ProgressDisplay.svelte index 951afd9..f6b2ed9 100644 --- a/frontend/src/lib/components/ProgressDisplay.svelte +++ b/frontend/src/lib/components/ProgressDisplay.svelte @@ -8,6 +8,7 @@ let statusLabel = $derived({ idle: 'Idle', running: 'Processing', + cancelling: 'Cancelling', compiling_video: 'Compiling Video', completed: 'Completed', cancelled: 'Cancelled', @@ -17,6 +18,7 @@ let statusClass = $derived({ idle: '', running: 'running', + cancelling: 'warning', compiling_video: 'running', completed: 'success', cancelled: 'warning', @@ -32,7 +34,7 @@ {/if} - {#if jobStatus === 'running' || jobStatus === 'compiling_video'} + {#if jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling'}
diff --git a/src/immich_api/mod.rs b/src/immich_api/mod.rs index 7cb38df..2cfa509 100644 --- a/src/immich_api/mod.rs +++ b/src/immich_api/mod.rs @@ -234,6 +234,39 @@ impl ImmichClient { let response: PeopleResponse = response.json().await?; Ok(response.people) } + + /// Get a person's thumbnail image. + /// Returns the image bytes and content-type. + pub async fn get_person_thumbnail(&self, person_id: &str) -> Result<(bytes::Bytes, String)> { + let url = format!("{}/people/{}/thumbnail", self.base_url, person_id); + // tracing::debug!("Fetching thumbnail from: {}", url); + + let response = self + .client + .get(&url) + .header("x-api-key", &self.api_key) + .send() + .await?; + + if !response.status().is_success() { + return Err(Error::ImmichApi(format!( + "Failed to get thumbnail for person {}: {}", + person_id, + response.status() + ))); + } + + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("image/jpeg") + .to_string(); + + let bytes = response.bytes().await?; + tracing::debug!("Thumbnail bytes received: {}", bytes.len()); + Ok((bytes, content_type)) + } } #[cfg(test)] diff --git a/src/job/mod.rs b/src/job/mod.rs index 4c50f15..e9909be 100644 --- a/src/job/mod.rs +++ b/src/job/mod.rs @@ -183,7 +183,15 @@ async fn run_job_inner( }; } - let result = process_single_asset(&client, &config, &asset, &face_data, &images_dir).await; + let result = process_single_asset( + &client, + &config, + &asset, + &face_data, + &images_dir, + &task_cancel_token, + ) + .await; // Update progress (only if not cancelled) if !task_cancel_token.is_cancelled() { @@ -205,36 +213,17 @@ async fn run_job_inner( handles.push(handle); } - // Collect abort handles for cancellation - let abort_handles: Vec<_> = handles.iter().map(|h| h.abort_handle()).collect(); - - // Spawn a task to monitor cancellation and abort all tasks if cancelled - let abort_handles_clone = abort_handles.clone(); - let cancel_monitor_token = cancel_token.clone(); - let cancel_monitor = tokio::spawn(async move { - cancel_monitor_token.cancelled().await; - for handle in abort_handles_clone { - handle.abort(); - } - }); - // Wait for all tasks to complete let mut results = Vec::with_capacity(handles.len()); for handle in handles { match handle.await { Ok(result) => results.push(result), - Err(e) if e.is_cancelled() => { - // Task was aborted due to cancellation - } Err(e) => { tracing::error!("Task panicked: {:?}", e); } } } - // Clean up the cancel monitor - cancel_monitor.abort(); - // Check if we were cancelled if cancel_token.is_cancelled() { return Err(Error::Cancelled); @@ -286,12 +275,17 @@ async fn run_job_inner( let output_video = config.output_dir.join("timelapse.mp4"); let state_clone = state.clone(); - compile_timelapse(&images_dir, &output_video, &config.video, move |frame, total| { - // Note: This callback is sync, so we can't easily update state here. - // The FFmpeg wrapper already handles this internally. - tracing::debug!("Video progress: {}/{}", frame, total); - let _ = (&state_clone, frame, total); // Suppress unused warning - }) + compile_timelapse( + &images_dir, + &output_video, + &config.video, + move |frame, total| { + // Note: This callback is sync, so we can't easily update state here. + // The FFmpeg wrapper already handles this internally. + tracing::debug!("Video progress: {}/{}", frame, total); + let _ = (&state_clone, frame, total); // Suppress unused warning + }, + ) .await?; Ok(output_video) @@ -323,6 +317,7 @@ async fn process_single_asset( asset: &Asset, face_data: &FaceData, output_dir: &Path, + cancel_token: &CancellationToken, ) -> AssetResult { let asset_id = &asset.id; @@ -348,6 +343,14 @@ async fn process_single_asset( }; } + // Check before download (potentially slow) + if cancel_token.is_cancelled() { + return AssetResult::Skipped { + asset_id: asset_id.clone(), + reason: "Cancelled".to_string(), + }; + } + // Download image let image_bytes = match client.download_asset(asset_id).await { Ok(bytes) => bytes, @@ -359,6 +362,14 @@ async fn process_single_asset( } }; + // Check after download, before CPU-intensive processing + if cancel_token.is_cancelled() { + return AssetResult::Skipped { + asset_id: asset_id.clone(), + reason: "Cancelled".to_string(), + }; + } + // Decode image let img = match image::load_from_memory(&image_bytes) { Ok(img) => img, @@ -381,6 +392,14 @@ async fn process_single_asset( } }; + // Check before file I/O + if cancel_token.is_cancelled() { + return AssetResult::Skipped { + asset_id: asset_id.clone(), + reason: "Cancelled".to_string(), + }; + } + // Generate timestamp-based filename for sorting let timestamp = asset .file_created_at @@ -392,7 +411,13 @@ async fn process_single_asset( // Sanitize timestamp for filename (replace colons and other invalid chars) let safe_timestamp: String = timestamp .chars() - .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) .collect(); let output_path = output_dir.join(format!("{}_{}.jpg", safe_timestamp, asset_id)); @@ -437,7 +462,9 @@ fn crop_face(img: &DynamicImage, face_data: &FaceData, output_size: u32) -> Resu let face_height = y2.saturating_sub(y1); if face_width == 0 || face_height == 0 { - return Err(Error::ImageProcessing("Invalid face bounding box".to_string())); + return Err(Error::ImageProcessing( + "Invalid face bounding box".to_string(), + )); } // Expand the crop area to include some context around the face @@ -451,8 +478,12 @@ fn crop_face(img: &DynamicImage, face_data: &FaceData, output_size: u32) -> Resu let center_y = (y1 + y2) / 2; // Calculate crop bounds, clamped to image dimensions - let crop_x1 = center_x.saturating_sub(crop_size / 2).min(img_width.saturating_sub(crop_size)); - let crop_y1 = center_y.saturating_sub(crop_size / 2).min(img_height.saturating_sub(crop_size)); + let crop_x1 = center_x + .saturating_sub(crop_size / 2) + .min(img_width.saturating_sub(crop_size)); + let crop_y1 = center_y + .saturating_sub(crop_size / 2) + .min(img_height.saturating_sub(crop_size)); // Ensure we don't exceed image bounds let actual_crop_size = crop_size.min(img_width - crop_x1).min(img_height - crop_y1); diff --git a/src/web/handlers.rs b/src/web/handlers.rs index b5dbd1d..2c4b11b 100644 --- a/src/web/handlers.rs +++ b/src/web/handlers.rs @@ -4,9 +4,10 @@ use crate::immich_api::ImmichClient; use crate::job::{run_job, JobParams}; use crate::web::state::{AppState, JobStatus, Progress}; use axum::{ - extract::State, - http::StatusCode, - response::{Html, IntoResponse, Json}, + body::Body, + extract::{Path, State}, + http::{header, StatusCode}, + response::{Html, IntoResponse, Json, Response}, routing::{get, post}, Router, }; @@ -27,6 +28,7 @@ pub fn create_router(state: AppState) -> Router { .route("/api/health", get(health_check)) .route("/api/connection", get(check_connection)) .route("/api/people", get(get_people)) + .route("/api/people/{person_id}/thumbnail", get(get_person_thumbnail)) .route("/api/progress", get(get_progress)) .route("/api/start", post(start_processing)) .route("/api/cancel", post(cancel_processing)) @@ -134,6 +136,43 @@ async fn get_people( Ok(Json(people_info)) } +/// Get a person's thumbnail image. +async fn get_person_thumbnail( + State(state): State, + Path(person_id): Path, +) -> Result, (StatusCode, String)> { + let config = state.config.read().await; + let client = ImmichClient::new(&config.api).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to create client: {}", e), + ) + })?; + + let (bytes, content_type) = client.get_person_thumbnail(&person_id).await.map_err(|e| { + tracing::error!("Thumbnail fetch failed for {}: {}", person_id, e); + ( + StatusCode::NOT_FOUND, + format!("Failed to get thumbnail: {}", e), + ) + })?; + + tracing::debug!( + "Thumbnail for {}: {} bytes, type: {}", + person_id, + bytes.len(), + content_type + ); + + // Return the image with appropriate headers + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header(header::CACHE_CONTROL, "public, max-age=3600") + .body(Body::from(bytes)) + .unwrap()) +} + /// Get current progress. #[derive(Serialize)] struct ProgressResponse { @@ -149,6 +188,7 @@ async fn get_progress(State(state): State) -> Json { let status_str = match &progress.status { JobStatus::Idle => "idle", JobStatus::Running => "running", + JobStatus::Cancelling => "cancelling", JobStatus::CompilingVideo => "compiling_video", JobStatus::Completed => "completed", JobStatus::Cancelled => "cancelled", diff --git a/src/web/state.rs b/src/web/state.rs index 8ad84ff..42c95bf 100644 --- a/src/web/state.rs +++ b/src/web/state.rs @@ -10,6 +10,7 @@ use tokio_util::sync::CancellationToken; pub enum JobStatus { Idle, Running, + Cancelling, CompilingVideo, Completed, Cancelled, @@ -75,6 +76,13 @@ impl AppState { 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 + 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