Improve the cancellation (instant now), use people's thumbnail in the webpage

This commit is contained in:
Arnaud_Cayrol 2026-01-26 18:55:03 +01:00
parent 5869d3c24b
commit 3fd9f747d7
8 changed files with 172 additions and 39 deletions

View file

@ -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"] }

View file

@ -70,7 +70,12 @@
{disabled}
>
<div class="avatar">
{(person.name || 'U').charAt(0).toUpperCase()}
<img
src="/api/people/{person.id}/thumbnail"
alt={person.name || 'Person'}
onerror={(e) => e.target.style.display = 'none'}
/>
<span class="fallback">{(person.name || 'U').charAt(0).toUpperCase()}</span>
</div>
<span class="name">{person.name || 'Unnamed'}</span>
</button>
@ -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 {

View file

@ -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 {

View file

@ -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}
</div>
{#if jobStatus === 'running' || jobStatus === 'compiling_video'}
{#if jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling'}
<div class="progress-bar">
<div class="progress-fill" style="width: {percentage}%"></div>
</div>

View file

@ -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)]

View file

@ -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);

View file

@ -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<AppState>,
Path(person_id): Path<String>,
) -> Result<Response<Body>, (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<AppState>) -> Json<ProgressResponse> {
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",

View file

@ -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