Add a galery view to manually cleanup the assets before timelapse compilation.

This commit is contained in:
Arnaud_Cayrol 2026-01-29 21:46:19 +01:00
parent eadd1b5d7b
commit 1278c6290c
5 changed files with 1046 additions and 34 deletions

View file

@ -1,6 +1,7 @@
<script>
import { onMount, onDestroy } from 'svelte';
import ConnectionStatus from './lib/components/ConnectionStatus.svelte';
import GalleryView from './lib/components/GalleryView.svelte';
import OutputManager from './lib/components/OutputManager.svelte';
import PeopleSelector from './lib/components/PeopleSelector.svelte';
import ProcessingControls from './lib/components/ProcessingControls.svelte';
@ -14,10 +15,32 @@
let progress = $state({ completed: 0, total: 0, message: '' });
let pollInterval = $state(null);
// View state management for gallery
let currentView = $state('main'); // 'main' | 'gallery'
let galleryFolder = $state(null);
// Output folder management
let outputFolderRefreshKey = $state(0);
let outputFolders = $state([]);
let isJobRunning = $derived(
jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling'
);
function handleFoldersLoaded(folders) {
outputFolders = folders;
}
function openGallery(folder) {
galleryFolder = folder;
currentView = 'gallery';
}
function closeGallery() {
galleryFolder = null;
currentView = 'main';
}
function handleConnectionChange(data) {
connectionOk = data.connected;
// Check for running job when connection is established
@ -62,12 +85,17 @@
const res = await fetch('/api/progress');
const data = await res.json();
const previousStatus = jobStatus;
jobStatus = data.status;
progress = data;
// Stop polling when job completes
if (data.status === 'completed' || data.status === 'cancelled' || data.status === 'error' || data.status === 'idle') {
stopPolling();
// Refresh output folders when job finishes (was running before)
if (previousStatus === 'running' || previousStatus === 'compiling_video' || previousStatus === 'cancelling') {
outputFolderRefreshKey++;
}
}
} catch (e) {
console.error('Poll failed:', e);
@ -102,41 +130,57 @@
</header>
{#if connectionOk}
<section class="settings">
<SettingsPanel disabled={isJobRunning} />
</section>
<section class="controls">
<PeopleSelector
onselect={handlePersonSelect}
disabled={isJobRunning}
/>
{#if selectedPerson && !isJobRunning}
<ProcessingControls
personId={selectedPerson.id}
personName={selectedPerson.name}
{jobStatus}
onupdate={handleJobUpdate}
{#if currentView === 'gallery' && galleryFolder}
<section class="gallery">
<GalleryView
folderName={galleryFolder.name}
onBack={closeGallery}
disabled={isJobRunning}
/>
</section>
{:else}
<section class="settings">
<SettingsPanel disabled={isJobRunning} />
</section>
<section class="controls">
<PeopleSelector
onselect={handlePersonSelect}
disabled={isJobRunning}
/>
{#if selectedPerson && !isJobRunning}
<ProcessingControls
personId={selectedPerson.id}
personName={selectedPerson.name}
{jobStatus}
{outputFolders}
onupdate={handleJobUpdate}
/>
{/if}
</section>
{#if jobStatus !== 'idle'}
<section class="progress">
<ProgressDisplay {jobStatus} {progress} />
</section>
{/if}
</section>
{#if jobStatus !== 'idle'}
<section class="progress">
<ProgressDisplay {jobStatus} {progress} />
{#if jobStatus === 'completed'}
<section class="results">
<ResultsView />
</section>
{/if}
<section class="output">
<OutputManager
disabled={isJobRunning}
onOpenGallery={openGallery}
refreshKey={outputFolderRefreshKey}
onFoldersLoaded={handleFoldersLoaded}
/>
</section>
{/if}
{#if jobStatus === 'completed'}
<section class="results">
<ResultsView />
</section>
{/if}
<section class="output">
<OutputManager disabled={isJobRunning} />
</section>
{:else}
<section class="not-connected">
<p>Connect to your Immich server to get started.</p>

View file

@ -0,0 +1,465 @@
<script>
import { onMount } from 'svelte';
let {
folderName,
onBack,
disabled = false
} = $props();
let images = $state([]);
let loading = $state(true);
let error = $state(null);
let selectedImages = $state(new Set());
let deleting = $state(false);
let compiling = $state(false);
let videoExists = $state(false);
let selectedCount = $derived(selectedImages.size);
let allSelected = $derived(images.length > 0 && selectedImages.size === images.length);
async function loadImages() {
loading = true;
error = null;
try {
const res = await fetch(`/api/output/${encodeURIComponent(folderName)}/images`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message || 'Failed to load images');
}
const data = await res.json();
images = data.images;
videoExists = data.video_exists;
// Clear selection when reloading
selectedImages = new Set();
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
function toggleSelect(filename) {
if (selectedImages.has(filename)) {
selectedImages.delete(filename);
selectedImages = new Set(selectedImages);
} else {
selectedImages.add(filename);
selectedImages = new Set(selectedImages);
}
}
function selectAll() {
selectedImages = new Set(images.map(img => img.filename));
}
function deselectAll() {
selectedImages = new Set();
}
async function deleteSelected() {
if (selectedImages.size === 0) return;
const count = selectedImages.size;
if (!confirm(`Delete ${count} selected image${count > 1 ? 's' : ''}? This cannot be undone.`)) {
return;
}
deleting = true;
try {
const res = await fetch(`/api/output/${encodeURIComponent(folderName)}/images`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filenames: Array.from(selectedImages) })
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message || 'Failed to delete images');
}
const result = await res.json();
if (result.failed_count > 0) {
alert(`Deleted ${result.deleted_count} images. ${result.failed_count} failed.`);
}
// Reload images
await loadImages();
} catch (e) {
alert(e.message);
} finally {
deleting = false;
}
}
async function compileVideo() {
if (!confirm('Compile video from these images?')) {
return;
}
compiling = true;
try {
const res = await fetch(`/api/output/${encodeURIComponent(folderName)}/compile`, {
method: 'POST'
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message || 'Failed to start video compilation');
}
// The compilation runs in background - return to main view to see progress
alert('Video compilation started. Return to main view to see progress.');
onBack?.();
} catch (e) {
alert(e.message);
compiling = false;
}
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
onMount(() => {
loadImages();
});
</script>
<div class="gallery-view">
<header class="gallery-header">
<button class="back-btn" onclick={onBack} disabled={disabled || deleting || compiling}>
&larr; Back
</button>
<h2>
{folderName}
{#if !loading}
<span class="image-count">({images.length} images)</span>
{/if}
</h2>
</header>
{#if loading}
<div class="status">Loading images...</div>
{:else if error}
<div class="error">{error}</div>
{:else if images.length === 0}
<div class="status empty">No images in this folder</div>
{:else}
<div class="toolbar">
<div class="selection-controls">
<button
class="toolbar-btn"
onclick={selectAll}
disabled={disabled || deleting || compiling || allSelected}
>
Select All
</button>
<button
class="toolbar-btn"
onclick={deselectAll}
disabled={disabled || deleting || compiling || selectedCount === 0}
>
Deselect All
</button>
<button
class="toolbar-btn delete-btn"
onclick={deleteSelected}
disabled={disabled || deleting || compiling || selectedCount === 0}
>
{#if deleting}
Deleting...
{:else}
Delete Selected ({selectedCount})
{/if}
</button>
</div>
</div>
<div class="image-grid">
{#each images as image}
<div
class="image-card"
class:selected={selectedImages.has(image.filename)}
onclick={() => toggleSelect(image.filename)}
role="checkbox"
aria-checked={selectedImages.has(image.filename)}
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && toggleSelect(image.filename)}
>
<div class="checkbox-overlay">
<span class="checkbox">{selectedImages.has(image.filename) ? '✓' : ''}</span>
</div>
<img
src="/output/{encodeURIComponent(folderName)}/images/{encodeURIComponent(image.filename)}"
alt={image.filename}
loading="lazy"
/>
<div class="image-info">
<span class="image-date">{image.filename.split('_')[0]}</span>
</div>
</div>
{/each}
</div>
<footer class="gallery-footer">
<div class="footer-info">
{selectedCount} selected
{#if videoExists}
<span class="video-badge">Video exists</span>
{/if}
</div>
<button
class="compile-btn"
onclick={compileVideo}
disabled={disabled || deleting || compiling || images.length === 0}
>
{#if compiling}
Starting...
{:else}
Compile Video
{/if}
</button>
</footer>
{/if}
</div>
<style>
.gallery-view {
background: #1a1a1a;
border-radius: 8px;
padding: 1.5rem;
min-height: 400px;
display: flex;
flex-direction: column;
}
.gallery-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #333;
}
.back-btn {
padding: 0.5rem 1rem;
background: #333;
border: none;
border-radius: 4px;
color: #e0e0e0;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s ease;
}
.back-btn:hover:not(:disabled) {
background: #444;
}
.back-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
h2 {
font-size: 1.125rem;
font-weight: 600;
color: #fff;
margin: 0;
}
.image-count {
font-weight: 400;
color: #888;
font-size: 0.875rem;
}
.status {
text-align: center;
padding: 3rem;
color: #888;
font-size: 0.875rem;
}
.status.empty {
color: #666;
font-style: italic;
}
.error {
text-align: center;
padding: 3rem;
color: #dc2626;
font-size: 0.875rem;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.selection-controls {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.toolbar-btn {
padding: 0.5rem 0.75rem;
background: #333;
border: none;
border-radius: 4px;
color: #e0e0e0;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s ease;
}
.toolbar-btn:hover:not(:disabled) {
background: #444;
}
.toolbar-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toolbar-btn.delete-btn:hover:not(:disabled) {
background: #dc2626;
color: #fff;
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.75rem;
flex: 1;
overflow-y: auto;
padding: 0.25rem;
}
.image-card {
position: relative;
aspect-ratio: 1;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.15s ease;
background: #252525;
}
.image-card:hover {
border-color: #444;
}
.image-card.selected {
border-color: #4f46e5;
}
.image-card:focus {
outline: 2px solid #4f46e5;
outline-offset: 2px;
}
.checkbox-overlay {
position: absolute;
top: 0.25rem;
left: 0.25rem;
width: 1.25rem;
height: 1.25rem;
background: rgba(0, 0, 0, 0.6);
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.image-card.selected .checkbox-overlay {
background: #4f46e5;
}
.checkbox {
color: #fff;
font-size: 0.75rem;
font-weight: bold;
}
.image-card img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0.25rem 0.5rem;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
}
.image-date {
font-size: 0.625rem;
color: #ccc;
}
.gallery-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #333;
}
.footer-info {
font-size: 0.875rem;
color: #888;
display: flex;
align-items: center;
gap: 0.75rem;
}
.video-badge {
background: #22c55e;
color: #0f0f0f;
font-size: 0.625rem;
font-weight: 600;
padding: 0.125rem 0.375rem;
border-radius: 2px;
text-transform: uppercase;
}
.compile-btn {
padding: 0.625rem 1.25rem;
background: #4f46e5;
border: none;
border-radius: 4px;
color: #fff;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.compile-btn:hover:not(:disabled) {
background: #4338ca;
}
.compile-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View file

@ -1,13 +1,20 @@
<script>
import { onMount } from 'svelte';
let { disabled = false } = $props();
let { disabled = false, onOpenGallery, refreshKey = 0, onFoldersLoaded } = $props();
let folders = $state([]);
let loading = $state(true);
let error = $state(null);
let deleting = $state(null);
// Re-fetch when refreshKey changes
$effect(() => {
if (refreshKey > 0) {
loadFolders();
}
});
async function loadFolders() {
loading = true;
error = null;
@ -15,6 +22,8 @@
const res = await fetch('/api/output');
if (!res.ok) throw new Error('Failed to load output folders');
folders = await res.json();
// Notify parent about loaded folders (for existing folder warnings)
onFoldersLoaded?.(folders);
} catch (e) {
error = e.message;
} finally {
@ -116,6 +125,13 @@
View
</a>
{/if}
<button
class="gallery-btn"
onclick={() => onOpenGallery?.(folder)}
disabled={disabled || deleting !== null}
>
Gallery
</button>
<button
class="delete-btn"
onclick={() => deleteFolder(folder.name)}
@ -273,6 +289,26 @@
background: #4f46e5;
}
.gallery-btn {
padding: 0.375rem 0.75rem;
background: #333;
border: none;
border-radius: 4px;
color: #e0e0e0;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s ease;
}
.gallery-btn:hover:not(:disabled) {
background: #4f46e5;
}
.gallery-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.delete-btn {
width: 2rem;
height: 2rem;

View file

@ -1,5 +1,5 @@
<script>
let { personId, personName, jobStatus, onupdate } = $props();
let { personId, personName, jobStatus, outputFolders = [], onupdate } = $props();
let dateFrom = $state('');
let dateTo = $state('');
@ -10,6 +10,29 @@
jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling'
);
// Match backend's sanitize_folder_name logic
// Uses Unicode-aware regex to match Rust's is_alphanumeric()
function sanitizeFolderName(name, id) {
const base = name && name.trim() ? name : id;
let sanitized = '';
for (const c of base) {
// \p{L} = Unicode letter, \p{N} = Unicode number (matches Rust's is_alphanumeric)
if (/^[\p{L}\p{N}\-_ ]$/u.test(c)) {
sanitized += c;
} else {
sanitized += '_';
}
}
sanitized = sanitized.trim();
return sanitized.length > 50 ? sanitized.slice(0, 50) : sanitized;
}
// Check if an output folder already exists for this person
let existingFolder = $derived.by(() => {
const expectedName = sanitizeFolderName(personName, personId);
return outputFolders.find(f => f.name === expectedName);
});
// Fetch asset count when personId changes
$effect(() => {
if (personId) {
@ -80,6 +103,25 @@
console.error('Cancel failed:', e);
}
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function handleStartClick() {
if (existingFolder) {
const folder = existingFolder;
const message = `"${folder.name}" already has ${folder.image_count} images (${formatSize(folder.size_bytes)})${folder.has_video ? ' and a compiled video' : ''}.\n\nClick OK to overwrite.`;
if (confirm(message)) {
startProcessing();
}
} else {
startProcessing();
}
}
</script>
<div class="processing-controls">
@ -113,7 +155,7 @@
</div>
<div class="actions">
<button class="start-btn" onclick={startProcessing}>
<button class="start-btn" onclick={handleStartClick}>
Start Processing
</button>
</div>
@ -228,7 +270,12 @@
color: #fff;
}
.start-btn:hover {
.start-btn:hover:not(:disabled) {
background: #4338ca;
}
.start-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View file

@ -43,6 +43,19 @@ pub fn create_router(state: AppState) -> Router {
.route("/api/output", get(list_output_folders))
.route("/api/output", delete(cleanup_all_output))
.route("/api/output/{folder_name}", delete(cleanup_output_folder))
.route("/api/output/{folder_name}/images", get(list_folder_images))
.route(
"/api/output/{folder_name}/images",
delete(delete_images_bulk),
)
.route(
"/api/output/{folder_name}/images/{filename}",
delete(delete_single_image),
)
.route(
"/api/output/{folder_name}/compile",
post(compile_folder_video),
)
.route("/api/config", get(get_config))
.route("/api/config", put(update_config))
// Serve output files (video, images)
@ -395,6 +408,37 @@ struct OutputFolderInfo {
has_video: bool,
}
/// Image information for gallery view.
#[derive(Serialize)]
struct ImageInfo {
filename: String,
size_bytes: u64,
}
/// Response for listing images in a folder.
#[derive(Serialize)]
struct FolderImagesResponse {
folder_name: String,
images: Vec<ImageInfo>,
total_count: u32,
total_size_bytes: u64,
video_exists: bool,
}
/// Request for bulk deleting images.
#[derive(Deserialize)]
struct BulkDeleteRequest {
filenames: Vec<String>,
}
/// Response for bulk delete operations.
#[derive(Serialize)]
struct BulkDeleteResponse {
deleted_count: u32,
failed_count: u32,
remaining_images: u32,
}
/// List all output folders with their stats.
async fn list_output_folders(
State(state): State<AppState>,
@ -574,6 +618,382 @@ async fn cleanup_output_folder(
}))
}
/// List images in a specific output folder.
async fn list_folder_images(
State(state): State<AppState>,
Path(folder_name): Path<String>,
) -> Result<Json<FolderImagesResponse>, (StatusCode, String)> {
let config = state.config.read().await;
// Sanitize folder name to prevent path traversal
if folder_name.contains("..") || folder_name.contains('/') || folder_name.contains('\\') {
return Err((StatusCode::BAD_REQUEST, "Invalid folder name".to_string()));
}
let images_dir = config.output_dir.join(&folder_name).join("images");
if !images_dir.exists() {
return Err((
StatusCode::NOT_FOUND,
format!("Folder '{}' not found or has no images", folder_name),
));
}
let mut images = Vec::new();
let mut total_size_bytes = 0u64;
let mut entries = tokio::fs::read_dir(&images_dir).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read images directory: {}", e),
)
})?;
while let Some(entry) = entries.next_entry().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read directory entry: {}", e),
)
})? {
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "jpg") {
let filename = entry.file_name().to_string_lossy().to_string();
let metadata = tokio::fs::metadata(&path).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read file metadata: {}", e),
)
})?;
let size_bytes = metadata.len();
total_size_bytes += size_bytes;
images.push(ImageInfo {
filename,
size_bytes,
});
}
}
// Sort by filename (chronological since filenames are timestamp-based)
images.sort_by(|a, b| a.filename.cmp(&b.filename));
let total_count = images.len() as u32;
let video_exists = config
.output_dir
.join(&folder_name)
.join("timelapse.mp4")
.exists();
Ok(Json(FolderImagesResponse {
folder_name,
images,
total_count,
total_size_bytes,
video_exists,
}))
}
/// Delete a single image from an output folder.
async fn delete_single_image(
State(state): State<AppState>,
Path((folder_name, filename)): Path<(String, String)>,
) -> Result<Json<StartResponse>, (StatusCode, String)> {
// Check if a job is running
{
let progress = state.progress.read().await;
if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo {
return Err((
StatusCode::CONFLICT,
"Cannot delete images while a job is running".to_string(),
));
}
}
let config = state.config.read().await;
// Sanitize folder name and filename to prevent path traversal
if folder_name.contains("..") || folder_name.contains('/') || folder_name.contains('\\') {
return Err((StatusCode::BAD_REQUEST, "Invalid folder name".to_string()));
}
if filename.contains("..") || filename.contains('/') || filename.contains('\\') {
return Err((StatusCode::BAD_REQUEST, "Invalid filename".to_string()));
}
if !filename.ends_with(".jpg") {
return Err((
StatusCode::BAD_REQUEST,
"Only .jpg files can be deleted".to_string(),
));
}
let file_path = config
.output_dir
.join(&folder_name)
.join("images")
.join(&filename);
if !file_path.exists() {
return Err((
StatusCode::NOT_FOUND,
format!("Image '{}' not found in folder '{}'", filename, folder_name),
));
}
tokio::fs::remove_file(&file_path).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to delete image: {}", e),
)
})?;
tracing::info!("Deleted image: {}/{}", folder_name, filename);
Ok(Json(StartResponse {
success: true,
message: format!("Deleted image '{}'", filename),
}))
}
/// Bulk delete images from an output folder.
async fn delete_images_bulk(
State(state): State<AppState>,
Path(folder_name): Path<String>,
Json(request): Json<BulkDeleteRequest>,
) -> Result<Json<BulkDeleteResponse>, (StatusCode, String)> {
// Check if a job is running
{
let progress = state.progress.read().await;
if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo {
return Err((
StatusCode::CONFLICT,
"Cannot delete images while a job is running".to_string(),
));
}
}
let config = state.config.read().await;
// Sanitize folder name
if folder_name.contains("..") || folder_name.contains('/') || folder_name.contains('\\') {
return Err((StatusCode::BAD_REQUEST, "Invalid folder name".to_string()));
}
let images_dir = config.output_dir.join(&folder_name).join("images");
if !images_dir.exists() {
return Err((
StatusCode::NOT_FOUND,
format!("Folder '{}' not found or has no images", folder_name),
));
}
let mut deleted_count = 0u32;
let mut failed_count = 0u32;
for filename in &request.filenames {
// Sanitize each filename
if filename.contains("..") || filename.contains('/') || filename.contains('\\') {
failed_count += 1;
continue;
}
if !filename.ends_with(".jpg") {
failed_count += 1;
continue;
}
let file_path = images_dir.join(filename);
if file_path.exists() {
match tokio::fs::remove_file(&file_path).await {
Ok(_) => deleted_count += 1,
Err(_) => failed_count += 1,
}
} else {
failed_count += 1;
}
}
// Count remaining images
let mut remaining_images = 0u32;
if let Ok(mut entries) = tokio::fs::read_dir(&images_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
if entry
.path()
.extension()
.map_or(false, |ext| ext == "jpg")
{
remaining_images += 1;
}
}
}
tracing::info!(
"Bulk deleted {} images from folder {} ({} failed)",
deleted_count,
folder_name,
failed_count
);
Ok(Json(BulkDeleteResponse {
deleted_count,
failed_count,
remaining_images,
}))
}
/// Compile video for a specific output folder.
async fn compile_folder_video(
State(state): State<AppState>,
Path(folder_name): Path<String>,
) -> Result<Json<StartResponse>, (StatusCode, String)> {
// Check if a job is running
{
let progress = state.progress.read().await;
if progress.status == JobStatus::Running || progress.status == JobStatus::CompilingVideo {
return Err((StatusCode::CONFLICT, "A job is already running".to_string()));
}
}
let config = state.config.read().await;
// Sanitize folder name
if folder_name.contains("..") || folder_name.contains('/') || folder_name.contains('\\') {
return Err((StatusCode::BAD_REQUEST, "Invalid folder name".to_string()));
}
let folder_path = config.output_dir.join(&folder_name);
let images_dir = folder_path.join("images");
if !images_dir.exists() {
return Err((
StatusCode::NOT_FOUND,
format!("Folder '{}' not found or has no images", folder_name),
));
}
// Count images to verify there are some
let mut image_count = 0u32;
if let Ok(mut entries) = tokio::fs::read_dir(&images_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
if entry
.path()
.extension()
.map_or(false, |ext| ext == "jpg")
{
image_count += 1;
}
}
}
if image_count == 0 {
return Err((
StatusCode::BAD_REQUEST,
"No images found in folder to compile".to_string(),
));
}
// Set status to compiling video
state
.update_progress(Progress {
status: JobStatus::CompilingVideo,
completed: 0,
total: image_count,
message: Some(format!("Compiling video for {}...", folder_name)),
skip_stats: SkipStats::default(),
person_id: None,
person_name: Some(folder_name.clone()),
})
.await;
// Create cancellation token
let cancel_token = state.create_cancel_token().await;
// Clone values needed for the async task
let video_config = config.video.clone();
let output_path = folder_path.join("timelapse.mp4");
let job_state = state.clone();
let folder_name_clone = folder_name.clone();
// Spawn the compilation job in the background
tokio::spawn(async move {
let result = crate::video::compile_timelapse(&images_dir, &output_path, &video_config, |current, total| {
// Check for cancellation
if cancel_token.is_cancelled() {
return;
}
// Update progress (fire and forget since we're in sync callback)
let state_clone = job_state.clone();
let folder_clone = folder_name_clone.clone();
tokio::spawn(async move {
state_clone
.update_progress(Progress {
status: JobStatus::CompilingVideo,
completed: current,
total,
message: Some(format!("Compiling video for {}...", folder_clone)),
skip_stats: SkipStats::default(),
person_id: None,
person_name: Some(folder_clone),
})
.await;
});
})
.await;
// Update final status
match result {
Ok(_) => {
tracing::info!("Video compilation complete for folder: {}", folder_name_clone);
job_state
.update_progress(Progress {
status: JobStatus::Completed,
completed: image_count,
total: image_count,
message: Some("Video compilation complete".to_string()),
skip_stats: SkipStats::default(),
person_id: None,
person_name: Some(folder_name_clone),
})
.await;
}
Err(e) => {
if cancel_token.is_cancelled() {
job_state
.update_progress(Progress {
status: JobStatus::Cancelled,
completed: 0,
total: image_count,
message: Some("Video compilation cancelled".to_string()),
skip_stats: SkipStats::default(),
person_id: None,
person_name: Some(folder_name_clone),
})
.await;
} else {
tracing::error!("Video compilation failed: {}", e);
job_state
.update_progress(Progress {
status: JobStatus::Error(e.to_string()),
completed: 0,
total: image_count,
message: Some(format!("Video compilation failed: {}", e)),
skip_stats: SkipStats::default(),
person_id: None,
person_name: Some(folder_name_clone),
})
.await;
}
}
}
job_state.clear_cancel_token().await;
});
Ok(Json(StartResponse {
success: true,
message: format!("Video compilation started for '{}'", folder_name),
}))
}
/// Configuration response (excludes sensitive API credentials).
/// Reuses config types which already derive Serialize.
#[derive(Serialize)]