Add Svelte 5 frontend and fix job cancellation

This commit is contained in:
Arnaud_Cayrol 2026-01-25 20:01:14 +01:00
parent 11598d2cbf
commit 5869d3c24b
18 changed files with 2509 additions and 48 deletions

View file

@ -40,6 +40,7 @@ uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
directories = "5"
bytes = "1"
tokio-util = "0.7"
[dev-dependencies]
tokio-test = "0.4"

3
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
dist/
.vite/

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Immich Selfie Timelapse</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1399
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

16
frontend/package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "immich-timelapse-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"svelte": "^5.0.0",
"vite": "^6.0.0"
}
}

View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4f46e5"/>
<stop offset="100%" style="stop-color:#7c3aed"/>
</linearGradient>
</defs>
<circle cx="50" cy="50" r="45" fill="url(#grad)"/>
<circle cx="50" cy="40" r="18" fill="#fff" opacity="0.9"/>
<path d="M25 70 Q50 95 75 70 Q75 55 50 55 Q25 55 25 70" fill="#fff" opacity="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 486 B

123
frontend/src/App.svelte Normal file
View file

@ -0,0 +1,123 @@
<script>
import ConnectionStatus from './lib/components/ConnectionStatus.svelte';
import PeopleSelector from './lib/components/PeopleSelector.svelte';
import ProcessingControls from './lib/components/ProcessingControls.svelte';
import ProgressDisplay from './lib/components/ProgressDisplay.svelte';
import ResultsView from './lib/components/ResultsView.svelte';
let connectionOk = $state(false);
let selectedPerson = $state(null);
let jobStatus = $state('idle');
let progress = $state({ completed: 0, total: 0, message: '' });
function handleConnectionChange(data) {
connectionOk = data.connected;
}
function handlePersonSelect(person) {
selectedPerson = person;
}
function handleJobUpdate(data) {
jobStatus = data.status;
progress = data;
}
</script>
<main>
<header>
<h1>Immich Selfie Timelapse</h1>
<ConnectionStatus onchange={handleConnectionChange} />
</header>
{#if connectionOk}
<section class="controls">
<PeopleSelector
onselect={handlePersonSelect}
disabled={jobStatus === 'running' || jobStatus === 'compiling_video'}
/>
{#if selectedPerson}
<ProcessingControls
personId={selectedPerson.id}
personName={selectedPerson.name}
{jobStatus}
onupdate={handleJobUpdate}
/>
{/if}
</section>
{#if jobStatus !== 'idle'}
<section class="progress">
<ProgressDisplay {jobStatus} {progress} />
</section>
{/if}
{#if jobStatus === 'completed'}
<section class="results">
<ResultsView />
</section>
{/if}
{:else}
<section class="not-connected">
<p>Connect to your Immich server to get started.</p>
<p class="hint">Make sure the backend is running and configured with your Immich API credentials.</p>
</section>
{/if}
</main>
<style>
:global(*) {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:global(body) {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #0f0f0f;
color: #e0e0e0;
line-height: 1.6;
}
main {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid #333;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
color: #fff;
}
section {
margin-bottom: 2rem;
}
.not-connected {
text-align: center;
padding: 3rem;
background: #1a1a1a;
border-radius: 8px;
}
.not-connected p {
margin-bottom: 0.5rem;
}
.hint {
font-size: 0.875rem;
color: #888;
}
</style>

View file

@ -0,0 +1,100 @@
<script>
import { onMount } from 'svelte';
let { onchange } = $props();
let status = $state('checking');
let version = $state(null);
let error = $state(null);
async function checkConnection() {
status = 'checking';
error = null;
try {
const res = await fetch('/api/connection');
const data = await res.json();
if (data.connected) {
status = 'connected';
version = data.version;
onchange?.({ connected: true, version: data.version });
} else {
status = 'error';
error = data.error || 'Connection failed';
onchange?.({ connected: false });
}
} catch (e) {
status = 'error';
error = 'Cannot reach backend server';
onchange?.({ connected: false });
}
}
onMount(() => {
checkConnection();
});
</script>
<div class="connection-status" class:connected={status === 'connected'} class:error={status === 'error'}>
{#if status === 'checking'}
<span class="indicator checking"></span>
<span>Connecting...</span>
{:else if status === 'connected'}
<span class="indicator connected"></span>
<span>Immich {version}</span>
{:else}
<span class="indicator error"></span>
<button onclick={checkConnection} class="retry">
{error} - Retry
</button>
{/if}
</div>
<style>
.connection-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
padding: 0.5rem 1rem;
border-radius: 9999px;
background: #1a1a1a;
}
.indicator {
width: 8px;
height: 8px;
border-radius: 50%;
}
.indicator.checking {
background: #f59e0b;
animation: pulse 1s infinite;
}
.indicator.connected {
background: #22c55e;
}
.indicator.error {
background: #ef4444;
}
.retry {
background: none;
border: none;
color: #ef4444;
cursor: pointer;
font-size: inherit;
}
.retry:hover {
text-decoration: underline;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>

View file

@ -0,0 +1,196 @@
<script>
import { onMount } from 'svelte';
let { disabled = false, onselect } = $props();
let people = $state([]);
let loading = $state(true);
let error = $state(null);
let searchQuery = $state('');
let selectedId = $state(null);
let filteredPeople = $derived(
people.filter(p => {
const name = p.name || 'Unnamed';
return name.toLowerCase().includes(searchQuery.toLowerCase());
})
);
async function loadPeople() {
loading = true;
error = null;
try {
const res = await fetch('/api/people');
if (!res.ok) throw new Error('Failed to load people');
people = await res.json();
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
function selectPerson(person) {
if (disabled) return;
selectedId = person.id;
onselect?.(person);
}
onMount(() => {
loadPeople();
});
</script>
<div class="people-selector">
<h2>Select Person</h2>
{#if loading}
<div class="loading">Loading people...</div>
{:else if error}
<div class="error">
{error}
<button onclick={loadPeople}>Retry</button>
</div>
{:else}
<input
type="text"
placeholder="Search people..."
bind:value={searchQuery}
{disabled}
class="search"
/>
<div class="people-grid">
{#each filteredPeople as person (person.id)}
<button
class="person-card"
class:selected={selectedId === person.id}
onclick={() => selectPerson(person)}
{disabled}
>
<div class="avatar">
{(person.name || 'U').charAt(0).toUpperCase()}
</div>
<span class="name">{person.name || 'Unnamed'}</span>
</button>
{:else}
<p class="no-results">No people found</p>
{/each}
</div>
{/if}
</div>
<style>
.people-selector {
background: #1a1a1a;
border-radius: 8px;
padding: 1.5rem;
}
h2 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 1rem;
color: #fff;
}
.search {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #333;
border-radius: 6px;
background: #0f0f0f;
color: #e0e0e0;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.search:focus {
outline: none;
border-color: #4f46e5;
}
.search:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.people-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.75rem;
max-height: 300px;
overflow-y: auto;
}
.person-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: #252525;
border: 2px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
}
.person-card:hover:not(:disabled) {
background: #303030;
}
.person-card.selected {
border-color: #4f46e5;
background: #2a2a4a;
}
.person-card:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #4f46e5, #7c3aed);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 1.25rem;
color: #fff;
}
.name {
font-size: 0.75rem;
color: #ccc;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.loading, .error, .no-results {
text-align: center;
padding: 2rem;
color: #888;
}
.error {
color: #ef4444;
}
.error button {
margin-top: 0.5rem;
padding: 0.5rem 1rem;
background: #333;
border: none;
border-radius: 4px;
color: #fff;
cursor: pointer;
}
</style>

View file

@ -0,0 +1,218 @@
<script>
import { onMount, onDestroy } from 'svelte';
let { personId, personName, jobStatus, onupdate } = $props();
let dateFrom = $state('');
let dateTo = $state('');
let pollInterval = $state(null);
let isRunning = $derived(jobStatus === 'running' || jobStatus === 'compiling_video');
async function startProcessing() {
try {
const res = await fetch('/api/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
person_id: personId,
date_from: dateFrom || null,
date_to: dateTo || null,
}),
});
const data = await res.json();
if (res.ok && data.success) {
startPolling();
} else {
onupdate?.({
status: 'error',
completed: 0,
total: 0,
message: data.message || 'Failed to start processing',
});
}
} catch (e) {
onupdate?.({
status: 'error',
completed: 0,
total: 0,
message: 'Network error: ' + e.message,
});
}
}
async function cancelProcessing() {
try {
await fetch('/api/cancel', { method: 'POST' });
} catch (e) {
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">
<h2>Create Timelapse</h2>
<p class="selected-person">
Selected: <strong>{personName || 'Unnamed'}</strong>
</p>
<div class="date-filters">
<label>
<span>From</span>
<input type="date" bind:value={dateFrom} disabled={isRunning} />
</label>
<label>
<span>To</span>
<input type="date" bind:value={dateTo} disabled={isRunning} />
</label>
</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}
</div>
</div>
<style>
.processing-controls {
background: #1a1a1a;
border-radius: 8px;
padding: 1.5rem;
margin-top: 1rem;
}
h2 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 1rem;
color: #fff;
}
.selected-person {
font-size: 0.875rem;
color: #888;
margin-bottom: 1rem;
}
.selected-person strong {
color: #e0e0e0;
}
.date-filters {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.date-filters label {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.date-filters span {
font-size: 0.75rem;
color: #888;
}
.date-filters input {
padding: 0.75rem;
border: 1px solid #333;
border-radius: 6px;
background: #0f0f0f;
color: #e0e0e0;
font-size: 0.875rem;
}
.date-filters input:focus {
outline: none;
border-color: #4f46e5;
}
.date-filters input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.actions {
display: flex;
gap: 1rem;
}
button {
flex: 1;
padding: 0.875rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.start-btn {
background: #4f46e5;
color: #fff;
}
.start-btn:hover {
background: #4338ca;
}
.cancel-btn {
background: #dc2626;
color: #fff;
}
.cancel-btn:hover {
background: #b91c1c;
}
</style>

View file

@ -0,0 +1,111 @@
<script>
let { jobStatus, progress } = $props();
let percentage = $derived(
progress.total > 0 ? Math.round((progress.completed / progress.total) * 100) : 0
);
let statusLabel = $derived({
idle: 'Idle',
running: 'Processing',
compiling_video: 'Compiling Video',
completed: 'Completed',
cancelled: 'Cancelled',
error: 'Error',
}[jobStatus] || jobStatus);
let statusClass = $derived({
idle: '',
running: 'running',
compiling_video: 'running',
completed: 'success',
cancelled: 'warning',
error: 'error',
}[jobStatus] || '');
</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>
{#if jobStatus === 'running' || jobStatus === 'compiling_video'}
<div class="progress-bar">
<div class="progress-fill" style="width: {percentage}%"></div>
</div>
{/if}
{#if progress.message}
<p class="message">{progress.message}</p>
{/if}
</div>
<style>
.progress-display {
background: #1a1a1a;
border-radius: 8px;
padding: 1.5rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.status {
font-weight: 600;
font-size: 0.875rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
background: #333;
}
.status.running {
background: #1e3a5f;
color: #60a5fa;
}
.status.success {
background: #14532d;
color: #4ade80;
}
.status.warning {
background: #422006;
color: #fbbf24;
}
.status.error {
background: #450a0a;
color: #f87171;
}
.count {
font-size: 0.875rem;
color: #888;
}
.progress-bar {
height: 8px;
background: #333;
border-radius: 4px;
overflow: hidden;
margin-bottom: 1rem;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4f46e5, #7c3aed);
transition: width 0.3s ease;
}
.message {
font-size: 0.875rem;
color: #888;
}
</style>

View file

@ -0,0 +1,166 @@
<script>
// Future: This component can be extended to show:
// - Preview grid of processed faces
// - Before/after alignment comparison
// - Skipped images with reasons
// - Face landmark visualization
let videoUrl = $state('/output/timelapse.mp4');
let videoError = $state(false);
function handleVideoError() {
videoError = true;
}
</script>
<div class="results-view">
<h2>Result</h2>
{#if videoError}
<div class="video-error">
<p>Video not available yet.</p>
<p class="hint">The video file may still be processing or the path may have changed.</p>
</div>
{:else}
<div class="video-container">
<video
controls
autoplay
loop
src={videoUrl}
onerror={handleVideoError}
>
<track kind="captions" />
Your browser does not support the video tag.
</video>
</div>
<div class="actions">
<a href={videoUrl} download="timelapse.mp4" class="download-btn">
Download Video
</a>
</div>
{/if}
<!-- Future: Face preview grid -->
<!--
<div class="preview-section">
<h3>Processed Faces</h3>
<div class="preview-grid">
{#each processedFaces as face}
<div class="face-preview">
<img src={face.url} alt="Processed face" />
<span class="timestamp">{face.timestamp}</span>
</div>
{/each}
</div>
</div>
-->
<!-- Future: Skipped images -->
<!--
<details class="skipped-section">
<summary>Skipped Images ({skippedCount})</summary>
<ul>
{#each skippedImages as img}
<li>{img.reason}</li>
{/each}
</ul>
</details>
-->
</div>
<style>
.results-view {
background: #1a1a1a;
border-radius: 8px;
padding: 1.5rem;
}
h2 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 1rem;
color: #fff;
}
.video-container {
position: relative;
border-radius: 8px;
overflow: hidden;
background: #000;
margin-bottom: 1rem;
}
video {
width: 100%;
display: block;
}
.video-error {
text-align: center;
padding: 3rem;
background: #252525;
border-radius: 8px;
}
.video-error p {
margin-bottom: 0.5rem;
}
.hint {
font-size: 0.875rem;
color: #666;
}
.actions {
display: flex;
gap: 1rem;
}
.download-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #22c55e;
color: #fff;
text-decoration: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600;
transition: background 0.15s ease;
}
.download-btn:hover {
background: #16a34a;
}
/* Future styles for preview grid */
/*
.preview-section {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #333;
}
.preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 0.5rem;
}
.face-preview img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 4px;
}
.skipped-section {
margin-top: 1rem;
font-size: 0.875rem;
color: #888;
}
*/
</style>

7
frontend/src/main.js Normal file
View file

@ -0,0 +1,7 @@
import { mount } from 'svelte';
import App from './App.svelte';
const target = document.getElementById('app');
if (target) {
mount(App, { target });
}

View file

@ -0,0 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
};

19
frontend/vite.config.js Normal file
View file

@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
});

View file

@ -19,7 +19,8 @@ use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use tokio::sync::{oneshot, Semaphore};
use tokio::sync::Semaphore;
use tokio_util::sync::CancellationToken;
/// Parameters for starting a processing job.
#[derive(Debug, Clone)]
@ -33,8 +34,11 @@ pub struct JobParams {
///
/// This is the main entry point for background job processing.
/// It handles progress reporting and cancellation.
pub async fn run_job(state: AppState, params: JobParams, cancel_rx: oneshot::Receiver<()>) {
let result = run_job_inner(state.clone(), params, cancel_rx).await;
pub async fn run_job(state: AppState, params: JobParams, cancel_token: CancellationToken) {
let result = run_job_inner(state.clone(), params, cancel_token).await;
// Clear the cancellation token when done
state.clear_cancel_token().await;
match result {
Ok(output_path) => {
@ -75,7 +79,7 @@ pub async fn run_job(state: AppState, params: JobParams, cancel_rx: oneshot::Rec
async fn run_job_inner(
state: AppState,
params: JobParams,
mut cancel_rx: oneshot::Receiver<()>,
cancel_token: CancellationToken,
) -> Result<PathBuf> {
let config = state.config.read().await.clone();
@ -97,7 +101,7 @@ async fn run_job_inner(
.await;
// Check for cancellation
if cancel_rx.try_recv().is_ok() {
if cancel_token.is_cancelled() {
return Err(Error::Cancelled);
}
@ -156,26 +160,43 @@ async fn run_job_inner(
let mut handles = Vec::with_capacity(assets_with_faces.len());
for (asset, face_data) in assets_with_faces {
// Check for cancellation before spawning more tasks
if cancel_token.is_cancelled() {
break;
}
let permit = semaphore.clone().acquire_owned().await.unwrap();
let client = client.clone();
let config = config.clone();
let images_dir = images_dir.clone();
let completed = completed.clone();
let state = state.clone();
let task_cancel_token = cancel_token.clone();
let handle = tokio::spawn(async move {
// Check cancellation at start of task
if task_cancel_token.is_cancelled() {
drop(permit);
return AssetResult::Skipped {
asset_id: asset.id.clone(),
reason: "Cancelled".to_string(),
};
}
let result = process_single_asset(&client, &config, &asset, &face_data, &images_dir).await;
// Update progress
let done = completed.fetch_add(1, Ordering::SeqCst) + 1;
state
.update_progress(Progress {
status: JobStatus::Running,
completed: done,
total,
message: Some(format!("Processing images... ({}/{})", done, total)),
})
.await;
// Update progress (only if not cancelled)
if !task_cancel_token.is_cancelled() {
let done = completed.fetch_add(1, Ordering::SeqCst) + 1;
state
.update_progress(Progress {
status: JobStatus::Running,
completed: done,
total,
message: Some(format!("Processing images... ({}/{})", done, total)),
})
.await;
}
drop(permit);
result
@ -184,22 +205,41 @@ async fn run_job_inner(
handles.push(handle);
}
// Check for cancellation periodically while waiting
// 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 {
tokio::select! {
_ = &mut cancel_rx => {
return Err(Error::Cancelled);
match handle.await {
Ok(result) => results.push(result),
Err(e) if e.is_cancelled() => {
// Task was aborted due to cancellation
}
result = handle => {
results.push(result.unwrap_or(AssetResult::Error {
asset_id: "unknown".to_string(),
error: "Task panicked".to_string(),
}));
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);
}
// Count results
let successful = results
.iter()
@ -228,7 +268,7 @@ async fn run_job_inner(
}
// Check for cancellation before video compilation
if cancel_rx.try_recv().is_ok() {
if cancel_token.is_cancelled() {
return Err(Error::Cancelled);
}

View file

@ -6,24 +6,58 @@ use crate::web::state::{AppState, JobStatus, Progress};
use axum::{
extract::State,
http::StatusCode,
response::Json,
response::{Html, IntoResponse, Json},
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use tower_http::services::ServeDir;
/// Create the router with all routes.
pub fn create_router(state: AppState) -> Router {
// Get output directory from config for serving results
let output_dir = state
.config
.try_read()
.map(|c| c.output_dir.clone())
.unwrap_or_else(|_| std::path::PathBuf::from("output"));
Router::new()
// API routes
.route("/api/health", get(health_check))
.route("/api/connection", get(check_connection))
.route("/api/people", get(get_people))
.route("/api/progress", get(get_progress))
.route("/api/start", post(start_processing))
.route("/api/cancel", post(cancel_processing))
// Serve output files (video, images)
.nest_service("/output", ServeDir::new(output_dir))
// Serve frontend static files (fallback to index.html for SPA routing)
.fallback_service(ServeDir::new("frontend/dist").fallback(get(serve_index)))
.with_state(state)
}
/// Serve index.html for SPA routing.
async fn serve_index() -> impl IntoResponse {
match tokio::fs::read_to_string("frontend/dist/index.html").await {
Ok(html) => Html(html).into_response(),
Err(_) => Html(
r#"<!DOCTYPE html>
<html>
<head><title>Immich Timelapse</title></head>
<body style="font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0f0f0f; color: #e0e0e0;">
<div style="text-align: center;">
<h1>Frontend not built</h1>
<p>Run <code style="background: #333; padding: 0.25rem 0.5rem; border-radius: 4px;">cd frontend && npm install && npm run build</code></p>
<p style="margin-top: 1rem; color: #888;">Or use <code style="background: #333; padding: 0.25rem 0.5rem; border-radius: 4px;">npm run dev</code> for development</p>
</div>
</body>
</html>"#,
)
.into_response(),
}
}
/// Health check endpoint.
async fn health_check() -> &'static str {
"OK"
@ -169,9 +203,8 @@ async fn start_processing(
})
.await;
// Create cancellation channel
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
state.set_cancel_sender(cancel_tx).await;
// Create cancellation token
let cancel_token = state.create_cancel_token().await;
tracing::info!(
"Starting processing for person {} (date range: {:?} - {:?})",
@ -189,7 +222,7 @@ async fn start_processing(
let job_state = state.clone();
tokio::spawn(async move {
run_job(job_state, job_params, cancel_rx).await;
run_job(job_state, job_params, cancel_token).await;
});
Ok(Json(StartResponse {
@ -203,15 +236,7 @@ async fn cancel_processing(State(state): State<AppState>) -> Json<StartResponse>
let cancelled = state.request_cancel().await;
if cancelled {
state
.update_progress(Progress {
status: JobStatus::Cancelled,
completed: 0,
total: 0,
message: Some("Cancelled by user".to_string()),
})
.await;
// The job will update its own status when it detects cancellation
Json(StartResponse {
success: true,
message: "Cancellation requested".to_string(),

View file

@ -3,6 +3,7 @@
use crate::config::Config;
use std::sync::Arc;
use tokio::sync::{broadcast, RwLock};
use tokio_util::sync::CancellationToken;
/// Job status.
#[derive(Debug, Clone, PartialEq, Eq)]
@ -47,8 +48,8 @@ pub struct AppState {
/// Channel for broadcasting progress updates.
pub progress_tx: broadcast::Sender<Progress>,
/// Cancellation signal sender.
pub cancel_tx: Arc<RwLock<Option<tokio::sync::oneshot::Sender<()>>>>,
/// Cancellation token for the current job.
pub cancel_token: Arc<RwLock<Option<CancellationToken>>>,
}
impl AppState {
@ -59,7 +60,7 @@ impl AppState {
config: Arc::new(RwLock::new(config)),
progress: Arc::new(RwLock::new(Progress::default())),
progress_tx,
cancel_tx: Arc::new(RwLock::new(None)),
cancel_token: Arc::new(RwLock::new(None)),
}
}
@ -71,17 +72,24 @@ impl AppState {
/// Request cancellation of the current job.
pub async fn request_cancel(&self) -> bool {
let mut cancel_tx = self.cancel_tx.write().await;
if let Some(tx) = cancel_tx.take() {
let _ = tx.send(());
let cancel_token = self.cancel_token.read().await;
if let Some(token) = cancel_token.as_ref() {
token.cancel();
true
} else {
false
}
}
/// Set the cancellation sender for a new job.
pub async fn set_cancel_sender(&self, tx: tokio::sync::oneshot::Sender<()>) {
*self.cancel_tx.write().await = Some(tx);
/// Create a new cancellation token for a job.
pub async fn create_cancel_token(&self) -> CancellationToken {
let token = CancellationToken::new();
*self.cancel_token.write().await = Some(token.clone());
token
}
/// Clear the cancellation token when job completes.
pub async fn clear_cancel_token(&self) {
*self.cancel_token.write().await = None;
}
}