Improve the cancellation (instant now), use people's thumbnail in the webpage
This commit is contained in:
parent
5869d3c24b
commit
3fd9f747d7
8 changed files with 172 additions and 39 deletions
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue