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()}
+

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