Allow background processing (can close web page and reopen)

This commit is contained in:
Arnaud_Cayrol 2026-01-28 20:35:43 +01:00
parent d52e334ba7
commit eadd1b5d7b
6 changed files with 184 additions and 78 deletions

View file

@ -1,4 +1,5 @@
<script>
import { onMount, onDestroy } from 'svelte';
import ConnectionStatus from './lib/components/ConnectionStatus.svelte';
import OutputManager from './lib/components/OutputManager.svelte';
import PeopleSelector from './lib/components/PeopleSelector.svelte';
@ -11,6 +12,7 @@
let selectedPerson = $state(null);
let jobStatus = $state('idle');
let progress = $state({ completed: 0, total: 0, message: '' });
let pollInterval = $state(null);
let isJobRunning = $derived(
jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling'
@ -18,6 +20,10 @@
function handleConnectionChange(data) {
connectionOk = data.connected;
// Check for running job when connection is established
if (data.connected) {
checkAndPollProgress();
}
}
function handlePersonSelect(person) {
@ -27,7 +33,66 @@
function handleJobUpdate(data) {
jobStatus = data.status;
progress = data;
// Start polling if job just started
if (data.status === 'running' || data.status === 'compiling_video') {
startPolling();
}
}
async function checkAndPollProgress() {
try {
const res = await fetch('/api/progress');
const data = await res.json();
jobStatus = data.status;
progress = data;
// If a job is running, start polling
if (data.status === 'running' || data.status === 'compiling_video' || data.status === 'cancelling') {
startPolling();
}
} catch (e) {
console.error('Failed to check progress:', e);
}
}
async function pollProgress() {
try {
const res = await fetch('/api/progress');
const data = await res.json();
jobStatus = data.status;
progress = data;
// Stop polling when job completes
if (data.status === 'completed' || data.status === 'cancelled' || data.status === 'error' || data.status === 'idle') {
stopPolling();
}
} catch (e) {
console.error('Poll failed:', e);
}
}
function startPolling() {
if (pollInterval) return; // Already polling
pollInterval = setInterval(pollProgress, 500);
}
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
onMount(() => {
// Initial check will happen when connection is established
});
onDestroy(() => {
stopPolling();
});
</script>
<main>
@ -47,7 +112,7 @@
disabled={isJobRunning}
/>
{#if selectedPerson}
{#if selectedPerson && !isJobRunning}
<ProcessingControls
personId={selectedPerson.id}
personName={selectedPerson.name}

View file

@ -1,11 +1,8 @@
<script>
import { onMount, onDestroy } from 'svelte';
let { personId, personName, jobStatus, onupdate } = $props();
let dateFrom = $state('');
let dateTo = $state('');
let pollInterval = $state(null);
let assetCount = $state(null);
let loadingCount = $state(false);
@ -51,7 +48,13 @@
const data = await res.json();
if (res.ok && data.success) {
startPolling();
// Notify parent to start polling
onupdate?.({
status: 'running',
completed: 0,
total: 0,
message: 'Starting...',
});
} else {
onupdate?.({
status: 'error',
@ -77,43 +80,6 @@
console.error('Cancel failed:', e);
}
}
async function pollProgress() {
try {
const res = await fetch('/api/progress');
const data = await res.json();
onupdate?.(data);
if (data.status === 'completed' || data.status === 'cancelled' || data.status === 'error') {
stopPolling();
}
} catch (e) {
console.error('Poll failed:', e);
}
}
function startPolling() {
stopPolling();
pollProgress();
pollInterval = setInterval(pollProgress, 500);
}
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
onMount(() => {
// Check if there's already a running job
pollProgress();
});
onDestroy(() => {
stopPolling();
});
</script>
<div class="processing-controls">
@ -147,15 +113,9 @@
</div>
<div class="actions">
{#if isRunning}
<button class="cancel-btn" onclick={cancelProcessing}>
Cancel
</button>
{:else}
<button class="start-btn" onclick={startProcessing}>
Start Processing
</button>
{/if}
<button class="start-btn" onclick={startProcessing}>
Start Processing
</button>
</div>
</div>
@ -271,13 +231,4 @@
.start-btn:hover {
background: #4338ca;
}
.cancel-btn {
background: #dc2626;
color: #fff;
}
.cancel-btn:hover {
background: #b91c1c;
}
</style>

View file

@ -25,6 +25,11 @@
error: 'error',
}[jobStatus] || '');
let canCancel = $derived(jobStatus === 'running' || jobStatus === 'compiling_video');
// Person being processed
let personDisplay = $derived(progress.person_name || progress.person_id || null);
// Skip statistics from the backend
let skipStats = $derived(progress.skip_stats || {
face_too_small: 0,
@ -39,8 +44,6 @@
total: 0,
});
let hasSkips = $derived(skipStats.total > 0);
// Calculate kept (successful) count: completed - skipped
let keptCount = $derived(Math.max(0, progress.completed - skipStats.total));
@ -58,14 +61,32 @@
{ label: 'Crop failed', count: skipStats.crop_failed },
].filter(r => r.count > 0)
);
async function cancelProcessing() {
try {
await fetch('/api/cancel', { method: 'POST' });
} catch (e) {
console.error('Cancel failed:', e);
}
}
</script>
<div class="progress-display">
<div class="header">
<span class="status {statusClass}">{statusLabel}</span>
{#if progress.total > 0}
<span class="count">{progress.completed} / {progress.total}</span>
{/if}
<div class="header-left">
<span class="status {statusClass}">{statusLabel}</span>
{#if personDisplay}
<span class="person-name">{personDisplay}</span>
{/if}
</div>
<div class="header-right">
{#if progress.total > 0}
<span class="count">{progress.completed} / {progress.total}</span>
{/if}
{#if canCancel}
<button class="cancel-btn" onclick={cancelProcessing}>Cancel</button>
{/if}
</div>
</div>
{#if jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling'}
@ -114,6 +135,18 @@
margin-bottom: 1rem;
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.person-name {
font-size: 0.875rem;
color: #e0e0e0;
font-weight: 500;
}
.status {
font-weight: 600;
font-size: 0.875rem;
@ -142,11 +175,33 @@
color: #f87171;
}
.header-right {
display: flex;
align-items: center;
gap: 1rem;
}
.count {
font-size: 0.875rem;
color: #888;
}
.cancel-btn {
padding: 0.375rem 0.75rem;
background: #dc2626;
border: none;
border-radius: 4px;
color: #fff;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.cancel-btn:hover {
background: #b91c1c;
}
.progress-bar {
height: 8px;
background: #333;

View file

@ -97,18 +97,20 @@ pub async fn run_job(state: AppState, params: JobParams, cancel_token: Cancellat
// Clear the cancellation token when done
state.clear_cancel_token().await;
// Get final skip stats from progress
let final_skip_stats = state.progress.read().await.skip_stats.clone();
// Get final state from progress (skip stats and person info)
let final_progress = state.progress.read().await.clone();
match result {
Ok(output_path) => {
state
.update_progress(Progress {
status: JobStatus::Completed,
completed: 0,
total: 0,
completed: final_progress.completed,
total: final_progress.total,
message: Some(format!("Video saved to: {}", output_path.display())),
skip_stats: final_skip_stats,
skip_stats: final_progress.skip_stats,
person_id: final_progress.person_id,
person_name: final_progress.person_name,
})
.await;
}
@ -116,10 +118,12 @@ pub async fn run_job(state: AppState, params: JobParams, cancel_token: Cancellat
state
.update_progress(Progress {
status: JobStatus::Cancelled,
completed: 0,
total: 0,
completed: final_progress.completed,
total: final_progress.total,
message: Some("Job cancelled by user".to_string()),
skip_stats: final_skip_stats,
skip_stats: final_progress.skip_stats,
person_id: final_progress.person_id,
person_name: final_progress.person_name,
})
.await;
}
@ -128,10 +132,12 @@ pub async fn run_job(state: AppState, params: JobParams, cancel_token: Cancellat
state
.update_progress(Progress {
status: JobStatus::Error(e.to_string()),
completed: 0,
total: 0,
completed: final_progress.completed,
total: final_progress.total,
message: Some(e.to_string()),
skip_stats: final_skip_stats,
skip_stats: final_progress.skip_stats,
person_id: final_progress.person_id,
person_name: final_progress.person_name,
})
.await;
}
@ -193,6 +199,8 @@ async fn run_job_inner(
total: 0,
message: Some("Fetching assets from Immich...".to_string()),
skip_stats: SkipStats::default(),
person_id: Some(params.person_id.clone()),
person_name: params.person_name.clone(),
})
.await;
@ -244,6 +252,8 @@ async fn run_job_inner(
total,
message: Some(format!("Processing {} images...", total)),
skip_stats: SkipStats::default(),
person_id: Some(params.person_id.clone()),
person_name: params.person_name.clone(),
})
.await;
@ -267,6 +277,10 @@ async fn run_job_inner(
let mut handles = Vec::with_capacity(assets_with_faces.len());
// Clone person info for use in spawned tasks
let task_person_id = params.person_id.clone();
let task_person_name = params.person_name.clone();
for (asset, face_data) in assets_with_faces {
// Check for cancellation before spawning more tasks
if cancel_token.is_cancelled() {
@ -295,6 +309,10 @@ async fn run_job_inner(
let skip_decode_failed = skip_decode_failed.clone();
let skip_crop_failed = skip_crop_failed.clone();
// Clone person info for this task
let person_id = task_person_id.clone();
let person_name = task_person_name.clone();
let handle = tokio::spawn(async move {
// Check cancellation at start of task
if task_cancel_token.is_cancelled() {
@ -353,6 +371,8 @@ async fn run_job_inner(
total,
message: Some(format!("Processing images... ({}/{})", done, total)),
skip_stats: current_skip_stats,
person_id: Some(person_id.clone()),
person_name: person_name.clone(),
})
.await;
}
@ -419,6 +439,8 @@ async fn run_job_inner(
total,
message: Some("Processing complete, preparing video...".to_string()),
skip_stats: skip_stats.clone(),
person_id: Some(params.person_id.clone()),
person_name: params.person_name.clone(),
})
.await;
@ -442,6 +464,8 @@ async fn run_job_inner(
total: successful as u32,
message: Some("Compiling video...".to_string()),
skip_stats: skip_stats.clone(),
person_id: Some(params.person_id.clone()),
person_name: params.person_name.clone(),
})
.await;

View file

@ -258,6 +258,8 @@ struct ProgressResponse {
total: u32,
message: Option<String>,
skip_stats: SkipStatsResponse,
person_id: Option<String>,
person_name: Option<String>,
}
async fn get_progress(State(state): State<AppState>) -> Json<ProgressResponse> {
@ -292,6 +294,8 @@ async fn get_progress(State(state): State<AppState>) -> Json<ProgressResponse> {
crop_failed: skip_stats.crop_failed,
total: skip_stats.total(),
},
person_id: progress.person_id.clone(),
person_name: progress.person_name.clone(),
})
}
@ -322,7 +326,7 @@ async fn start_processing(
}
}
// Reset progress
// Reset progress with person info
state
.update_progress(Progress {
status: JobStatus::Running,
@ -330,6 +334,8 @@ async fn start_processing(
total: 0,
message: Some("Starting...".to_string()),
skip_stats: SkipStats::default(),
person_id: Some(request.person_id.clone()),
person_name: request.person_name.clone(),
})
.await;

View file

@ -54,6 +54,9 @@ pub struct Progress {
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 {
@ -64,6 +67,8 @@ impl Default for Progress {
total: 0,
message: None,
skip_stats: SkipStats::default(),
person_id: None,
person_name: None,
}
}
}