From 499094c25aa188edb47431410951a976b4fdee27 Mon Sep 17 00:00:00 2001 From: Arnaud_Cayrol Date: Fri, 30 Jan 2026 18:23:16 +0100 Subject: [PATCH] Improvements to the video handling (download, gallery view etc..) --- frontend/src/App.svelte | 54 +++++++++++++++++-- .../lib/components/ConnectionStatus.svelte | 2 +- .../src/lib/components/GalleryView.svelte | 6 ++- .../src/lib/components/OutputManager.svelte | 26 ++++++--- .../src/lib/components/PeopleSelector.svelte | 3 +- .../lib/components/ProcessingControls.svelte | 2 +- .../src/lib/components/ProgressDisplay.svelte | 2 +- .../src/lib/components/ResultsView.svelte | 17 ++++-- .../src/lib/components/SettingsPanel.svelte | 9 ++-- src/immich_api/mod.rs | 1 - src/job/mod.rs | 3 +- src/web/handlers.rs | 19 ++++--- src/web/state.rs | 10 ++++ 13 files changed, 123 insertions(+), 31 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 26460ed..b9de5fb 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -18,6 +18,7 @@ // View state management for gallery let currentView = $state('main'); // 'main' | 'gallery' let galleryFolder = $state(null); + let savedScrollPosition = $state(0); // Output folder management let outputFolderRefreshKey = $state(0); @@ -27,13 +28,48 @@ jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling' ); + // Match backend's sanitize_folder_name logic for video path construction + function sanitizeFolderName(name, id) { + const base = name && name.trim() ? name : id; + let sanitized = ''; + for (const c of base) { + if (/^[\p{L}\p{N}\-_ ]$/u.test(c)) { + sanitized += c; + } else { + sanitized += '_'; + } + } + sanitized = sanitized.trim(); + return sanitized.length > 50 ? sanitized.slice(0, 50) : sanitized; + } + + // Compute folder name from progress for video display + let completedFolderName = $derived( + progress.person_name || progress.person_id + ? sanitizeFolderName(progress.person_name, progress.person_id) + : null + ); + function handleFoldersLoaded(folders) { outputFolders = folders; } + function handleFolderDeleted(folderName) { + // If the deleted folder matches the completed job's folder, reset to idle + // folderName === null means all folders were deleted + if (jobStatus === 'completed' && (folderName === null || folderName === completedFolderName)) { + jobStatus = 'idle'; + progress = { completed: 0, total: 0, message: '' }; + } + } + function openGallery(folder) { + // Save scroll position before switching to gallery + savedScrollPosition = window.scrollY; galleryFolder = folder; currentView = 'gallery'; + // Scroll to top for gallery view + window.scrollTo(0, 0); } function closeGallery() { @@ -41,6 +77,10 @@ currentView = 'main'; // Check for any running job (e.g., video compilation started from gallery) checkAndPollProgress(); + // Restore scroll position after DOM updates + requestAnimationFrame(() => { + window.scrollTo(0, savedScrollPosition); + }); } function handleConnectionChange(data) { @@ -70,11 +110,16 @@ const res = await fetch('/api/progress'); const data = await res.json(); - jobStatus = data.status; - progress = data; + // Only restore status if a job is actively running, or if we're not in idle state. + // This prevents restoring 'completed' status after we've manually dismissed it. + const isActiveJob = data.status === 'running' || data.status === 'compiling_video' || data.status === 'cancelling'; + if (isActiveJob || jobStatus !== 'idle') { + jobStatus = data.status; + progress = data; + } // If a job is running, start polling - if (data.status === 'running' || data.status === 'compiling_video' || data.status === 'cancelling') { + if (isActiveJob) { startPolling(); } } catch (e) { @@ -170,7 +215,7 @@ {#if jobStatus === 'completed'}
- +
{/if} @@ -180,6 +225,7 @@ onOpenGallery={openGallery} refreshKey={outputFolderRefreshKey} onFoldersLoaded={handleFoldersLoaded} + onFolderDeleted={handleFolderDeleted} /> {/if} diff --git a/frontend/src/lib/components/ConnectionStatus.svelte b/frontend/src/lib/components/ConnectionStatus.svelte index 18833dd..ef57576 100644 --- a/frontend/src/lib/components/ConnectionStatus.svelte +++ b/frontend/src/lib/components/ConnectionStatus.svelte @@ -45,7 +45,7 @@ Immich {version} {:else} - {/if} diff --git a/frontend/src/lib/components/GalleryView.svelte b/frontend/src/lib/components/GalleryView.svelte index 0de3d7a..657a88a 100644 --- a/frontend/src/lib/components/GalleryView.svelte +++ b/frontend/src/lib/components/GalleryView.svelte @@ -131,7 +131,7 @@ diff --git a/frontend/src/lib/components/ResultsView.svelte b/frontend/src/lib/components/ResultsView.svelte index a9cf320..c358944 100644 --- a/frontend/src/lib/components/ResultsView.svelte +++ b/frontend/src/lib/components/ResultsView.svelte @@ -5,7 +5,13 @@ // - Skipped images with reasons // - Face landmark visualization - let videoUrl = $state('/output/timelapse.mp4'); + let { folderName = null } = $props(); + + let videoUrl = $derived( + folderName + ? `/output/${encodeURIComponent(folderName)}/${encodeURIComponent(folderName)}.mp4` + : null + ); let videoError = $state(false); function handleVideoError() { @@ -16,7 +22,12 @@

Result

- {#if videoError} + {#if !videoUrl} +
+

No video available.

+

Person information was not provided.

+
+ {:else if videoError}

Video not available yet.

The video file may still be processing or the path may have changed.

@@ -36,7 +47,7 @@
diff --git a/frontend/src/lib/components/SettingsPanel.svelte b/frontend/src/lib/components/SettingsPanel.svelte index d7d6e5f..dbbfbac 100644 --- a/frontend/src/lib/components/SettingsPanel.svelte +++ b/frontend/src/lib/components/SettingsPanel.svelte @@ -79,7 +79,7 @@
- @@ -90,11 +90,12 @@

Loading settings...

{:else if error}

{error}

- + {:else}
- {#if saveMessage} diff --git a/src/immich_api/mod.rs b/src/immich_api/mod.rs index 2cfa509..9c78ccb 100644 --- a/src/immich_api/mod.rs +++ b/src/immich_api/mod.rs @@ -264,7 +264,6 @@ impl ImmichClient { .to_string(); let bytes = response.bytes().await?; - tracing::debug!("Thumbnail bytes received: {}", bytes.len()); Ok((bytes, content_type)) } } diff --git a/src/job/mod.rs b/src/job/mod.rs index 1ab2fd4..bf4ceed 100644 --- a/src/job/mod.rs +++ b/src/job/mod.rs @@ -180,9 +180,10 @@ async fn run_job_inner( None }; + let video_filename = format!("{}.mp4", folder_name); let output_dirs = OutputDirs { images: images_dir.clone(), - video: person_dir.join("timelapse.mp4"), + video: person_dir.join(&video_filename), debug, }; diff --git a/src/web/handlers.rs b/src/web/handlers.rs index 6840765..68c8794 100644 --- a/src/web/handlers.rs +++ b/src/web/handlers.rs @@ -339,9 +339,9 @@ async fn start_processing( } } - // Reset progress with person info + // Reset progress with person info (bypasses terminal state check) state - .update_progress(Progress { + .reset_progress(Progress { status: JobStatus::Running, completed: 0, total: 0, @@ -485,8 +485,9 @@ async fn list_output_folders( } } - // Check for video file - let has_video = path.join("timelapse.mp4").exists(); + // Check for video file (named after the folder/person) + let video_filename = format!("{}.mp4", name); + let has_video = path.join(&video_filename).exists(); folders.push(OutputFolderInfo { name, @@ -678,10 +679,11 @@ async fn list_folder_images( images.sort_by(|a, b| a.filename.cmp(&b.filename)); let total_count = images.len() as u32; + let video_filename = format!("{}.mp4", folder_name); let video_exists = config .output_dir .join(&folder_name) - .join("timelapse.mp4") + .join(&video_filename) .exists(); Ok(Json(FolderImagesResponse { @@ -890,9 +892,9 @@ async fn compile_folder_video( )); } - // Set status to compiling video + // Set status to compiling video (bypasses terminal state check) state - .update_progress(Progress { + .reset_progress(Progress { status: JobStatus::CompilingVideo, completed: 0, total: image_count, @@ -908,7 +910,8 @@ async fn compile_folder_video( // Clone values needed for the async task let video_config = config.video.clone(); - let output_path = folder_path.join("timelapse.mp4"); + let video_filename = format!("{}.mp4", folder_name); + let output_path = folder_path.join(&video_filename); let job_state = state.clone(); let folder_name_clone = folder_name.clone(); diff --git a/src/web/state.rs b/src/web/state.rs index b9f292c..da73984 100644 --- a/src/web/state.rs +++ b/src/web/state.rs @@ -133,6 +133,16 @@ impl AppState { let _ = self.progress_tx.send(progress); } + /// Reset progress for a new job, bypassing the terminal state check. + /// + /// Use this when starting a new job to clear any previous terminal state + /// (Completed, Cancelled, Error) that would otherwise block progress updates. + pub async fn reset_progress(&self, progress: Progress) { + let mut current = self.progress.write().await; + *current = progress.clone(); + let _ = self.progress_tx.send(progress); + } + /// Request cancellation of the current job. pub async fn request_cancel(&self) -> bool { let cancel_token = self.cancel_token.read().await;