Add Svelte 5 frontend and fix job cancellation
This commit is contained in:
parent
11598d2cbf
commit
5869d3c24b
18 changed files with 2509 additions and 48 deletions
|
|
@ -40,6 +40,7 @@ uuid = { version = "1", features = ["v4"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
directories = "5"
|
directories = "5"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
|
tokio-util = "0.7"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = "0.4"
|
tokio-test = "0.4"
|
||||||
|
|
|
||||||
3
frontend/.gitignore
vendored
Normal file
3
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal 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
1399
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
16
frontend/package.json
Normal file
16
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/public/favicon.svg
Normal file
11
frontend/public/favicon.svg
Normal 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
123
frontend/src/App.svelte
Normal 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>
|
||||||
100
frontend/src/lib/components/ConnectionStatus.svelte
Normal file
100
frontend/src/lib/components/ConnectionStatus.svelte
Normal 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>
|
||||||
196
frontend/src/lib/components/PeopleSelector.svelte
Normal file
196
frontend/src/lib/components/PeopleSelector.svelte
Normal 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>
|
||||||
218
frontend/src/lib/components/ProcessingControls.svelte
Normal file
218
frontend/src/lib/components/ProcessingControls.svelte
Normal 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>
|
||||||
111
frontend/src/lib/components/ProgressDisplay.svelte
Normal file
111
frontend/src/lib/components/ProgressDisplay.svelte
Normal 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>
|
||||||
166
frontend/src/lib/components/ResultsView.svelte
Normal file
166
frontend/src/lib/components/ResultsView.svelte
Normal 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
7
frontend/src/main.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { mount } from 'svelte';
|
||||||
|
import App from './App.svelte';
|
||||||
|
|
||||||
|
const target = document.getElementById('app');
|
||||||
|
if (target) {
|
||||||
|
mount(App, { target });
|
||||||
|
}
|
||||||
5
frontend/svelte.config.js
Normal file
5
frontend/svelte.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
};
|
||||||
19
frontend/vite.config.js
Normal file
19
frontend/vite.config.js
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -19,7 +19,8 @@ use std::io::Cursor;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{oneshot, Semaphore};
|
use tokio::sync::Semaphore;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
/// Parameters for starting a processing job.
|
/// Parameters for starting a processing job.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -33,8 +34,11 @@ pub struct JobParams {
|
||||||
///
|
///
|
||||||
/// This is the main entry point for background job processing.
|
/// This is the main entry point for background job processing.
|
||||||
/// It handles progress reporting and cancellation.
|
/// It handles progress reporting and cancellation.
|
||||||
pub async fn run_job(state: AppState, params: JobParams, cancel_rx: oneshot::Receiver<()>) {
|
pub async fn run_job(state: AppState, params: JobParams, cancel_token: CancellationToken) {
|
||||||
let result = run_job_inner(state.clone(), params, cancel_rx).await;
|
let result = run_job_inner(state.clone(), params, cancel_token).await;
|
||||||
|
|
||||||
|
// Clear the cancellation token when done
|
||||||
|
state.clear_cancel_token().await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(output_path) => {
|
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(
|
async fn run_job_inner(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
params: JobParams,
|
params: JobParams,
|
||||||
mut cancel_rx: oneshot::Receiver<()>,
|
cancel_token: CancellationToken,
|
||||||
) -> Result<PathBuf> {
|
) -> Result<PathBuf> {
|
||||||
let config = state.config.read().await.clone();
|
let config = state.config.read().await.clone();
|
||||||
|
|
||||||
|
|
@ -97,7 +101,7 @@ async fn run_job_inner(
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Check for cancellation
|
// Check for cancellation
|
||||||
if cancel_rx.try_recv().is_ok() {
|
if cancel_token.is_cancelled() {
|
||||||
return Err(Error::Cancelled);
|
return Err(Error::Cancelled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,26 +160,43 @@ async fn run_job_inner(
|
||||||
let mut handles = Vec::with_capacity(assets_with_faces.len());
|
let mut handles = Vec::with_capacity(assets_with_faces.len());
|
||||||
|
|
||||||
for (asset, face_data) in assets_with_faces {
|
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 permit = semaphore.clone().acquire_owned().await.unwrap();
|
||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
let config = config.clone();
|
let config = config.clone();
|
||||||
let images_dir = images_dir.clone();
|
let images_dir = images_dir.clone();
|
||||||
let completed = completed.clone();
|
let completed = completed.clone();
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
|
let task_cancel_token = cancel_token.clone();
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
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;
|
let result = process_single_asset(&client, &config, &asset, &face_data, &images_dir).await;
|
||||||
|
|
||||||
// Update progress
|
// Update progress (only if not cancelled)
|
||||||
let done = completed.fetch_add(1, Ordering::SeqCst) + 1;
|
if !task_cancel_token.is_cancelled() {
|
||||||
state
|
let done = completed.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
.update_progress(Progress {
|
state
|
||||||
status: JobStatus::Running,
|
.update_progress(Progress {
|
||||||
completed: done,
|
status: JobStatus::Running,
|
||||||
total,
|
completed: done,
|
||||||
message: Some(format!("Processing images... ({}/{})", done, total)),
|
total,
|
||||||
})
|
message: Some(format!("Processing images... ({}/{})", done, total)),
|
||||||
.await;
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
drop(permit);
|
drop(permit);
|
||||||
result
|
result
|
||||||
|
|
@ -184,22 +205,41 @@ async fn run_job_inner(
|
||||||
handles.push(handle);
|
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());
|
let mut results = Vec::with_capacity(handles.len());
|
||||||
for handle in handles {
|
for handle in handles {
|
||||||
tokio::select! {
|
match handle.await {
|
||||||
_ = &mut cancel_rx => {
|
Ok(result) => results.push(result),
|
||||||
return Err(Error::Cancelled);
|
Err(e) if e.is_cancelled() => {
|
||||||
|
// Task was aborted due to cancellation
|
||||||
}
|
}
|
||||||
result = handle => {
|
Err(e) => {
|
||||||
results.push(result.unwrap_or(AssetResult::Error {
|
tracing::error!("Task panicked: {:?}", e);
|
||||||
asset_id: "unknown".to_string(),
|
|
||||||
error: "Task panicked".to_string(),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up the cancel monitor
|
||||||
|
cancel_monitor.abort();
|
||||||
|
|
||||||
|
// Check if we were cancelled
|
||||||
|
if cancel_token.is_cancelled() {
|
||||||
|
return Err(Error::Cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
// Count results
|
// Count results
|
||||||
let successful = results
|
let successful = results
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -228,7 +268,7 @@ async fn run_job_inner(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for cancellation before video compilation
|
// Check for cancellation before video compilation
|
||||||
if cancel_rx.try_recv().is_ok() {
|
if cancel_token.is_cancelled() {
|
||||||
return Err(Error::Cancelled);
|
return Err(Error::Cancelled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,24 +6,58 @@ use crate::web::state::{AppState, JobStatus, Progress};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::State,
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::Json,
|
response::{Html, IntoResponse, Json},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
/// Create the router with all routes.
|
/// Create the router with all routes.
|
||||||
pub fn create_router(state: AppState) -> Router {
|
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()
|
Router::new()
|
||||||
|
// API routes
|
||||||
.route("/api/health", get(health_check))
|
.route("/api/health", get(health_check))
|
||||||
.route("/api/connection", get(check_connection))
|
.route("/api/connection", get(check_connection))
|
||||||
.route("/api/people", get(get_people))
|
.route("/api/people", get(get_people))
|
||||||
.route("/api/progress", get(get_progress))
|
.route("/api/progress", get(get_progress))
|
||||||
.route("/api/start", post(start_processing))
|
.route("/api/start", post(start_processing))
|
||||||
.route("/api/cancel", post(cancel_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)
|
.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.
|
/// Health check endpoint.
|
||||||
async fn health_check() -> &'static str {
|
async fn health_check() -> &'static str {
|
||||||
"OK"
|
"OK"
|
||||||
|
|
@ -169,9 +203,8 @@ async fn start_processing(
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Create cancellation channel
|
// Create cancellation token
|
||||||
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
|
let cancel_token = state.create_cancel_token().await;
|
||||||
state.set_cancel_sender(cancel_tx).await;
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Starting processing for person {} (date range: {:?} - {:?})",
|
"Starting processing for person {} (date range: {:?} - {:?})",
|
||||||
|
|
@ -189,7 +222,7 @@ async fn start_processing(
|
||||||
|
|
||||||
let job_state = state.clone();
|
let job_state = state.clone();
|
||||||
tokio::spawn(async move {
|
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 {
|
Ok(Json(StartResponse {
|
||||||
|
|
@ -203,15 +236,7 @@ async fn cancel_processing(State(state): State<AppState>) -> Json<StartResponse>
|
||||||
let cancelled = state.request_cancel().await;
|
let cancelled = state.request_cancel().await;
|
||||||
|
|
||||||
if cancelled {
|
if cancelled {
|
||||||
state
|
// The job will update its own status when it detects cancellation
|
||||||
.update_progress(Progress {
|
|
||||||
status: JobStatus::Cancelled,
|
|
||||||
completed: 0,
|
|
||||||
total: 0,
|
|
||||||
message: Some("Cancelled by user".to_string()),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Json(StartResponse {
|
Json(StartResponse {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Cancellation requested".to_string(),
|
message: "Cancellation requested".to_string(),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{broadcast, RwLock};
|
use tokio::sync::{broadcast, RwLock};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
/// Job status.
|
/// Job status.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|
@ -47,8 +48,8 @@ pub struct AppState {
|
||||||
/// Channel for broadcasting progress updates.
|
/// Channel for broadcasting progress updates.
|
||||||
pub progress_tx: broadcast::Sender<Progress>,
|
pub progress_tx: broadcast::Sender<Progress>,
|
||||||
|
|
||||||
/// Cancellation signal sender.
|
/// Cancellation token for the current job.
|
||||||
pub cancel_tx: Arc<RwLock<Option<tokio::sync::oneshot::Sender<()>>>>,
|
pub cancel_token: Arc<RwLock<Option<CancellationToken>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
|
|
@ -59,7 +60,7 @@ impl AppState {
|
||||||
config: Arc::new(RwLock::new(config)),
|
config: Arc::new(RwLock::new(config)),
|
||||||
progress: Arc::new(RwLock::new(Progress::default())),
|
progress: Arc::new(RwLock::new(Progress::default())),
|
||||||
progress_tx,
|
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.
|
/// Request cancellation of the current job.
|
||||||
pub async fn request_cancel(&self) -> bool {
|
pub async fn request_cancel(&self) -> bool {
|
||||||
let mut cancel_tx = self.cancel_tx.write().await;
|
let cancel_token = self.cancel_token.read().await;
|
||||||
if let Some(tx) = cancel_tx.take() {
|
if let Some(token) = cancel_token.as_ref() {
|
||||||
let _ = tx.send(());
|
token.cancel();
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the cancellation sender for a new job.
|
/// Create a new cancellation token for a job.
|
||||||
pub async fn set_cancel_sender(&self, tx: tokio::sync::oneshot::Sender<()>) {
|
pub async fn create_cancel_token(&self) -> CancellationToken {
|
||||||
*self.cancel_tx.write().await = Some(tx);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue