Implement websocket instead of polling for frontend progress update
This commit is contained in:
parent
d7b0238c5b
commit
291989cf4a
5 changed files with 175 additions and 54 deletions
|
|
@ -42,6 +42,7 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||||
directories = "5"
|
directories = "5"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
tokio-util = "0.7"
|
tokio-util = "0.7"
|
||||||
|
futures-util = "0.3"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@
|
||||||
import SettingsPanel from './lib/components/SettingsPanel.svelte';
|
import SettingsPanel from './lib/components/SettingsPanel.svelte';
|
||||||
|
|
||||||
// Configuration constants
|
// Configuration constants
|
||||||
const POLL_INTERVAL_MS = 500; // Progress polling interval during job execution
|
|
||||||
const STORAGE_KEY_PERSON = 'immich-timelapse-selected-person';
|
const STORAGE_KEY_PERSON = 'immich-timelapse-selected-person';
|
||||||
|
const WS_RECONNECT_DELAY_MS = 1000; // WebSocket reconnection delay
|
||||||
|
|
||||||
// Load persisted state from localStorage
|
// Load persisted state from localStorage
|
||||||
function loadPersistedPersonId() {
|
function loadPersistedPersonId() {
|
||||||
|
|
@ -44,7 +44,8 @@
|
||||||
let initialSelectedPersonId = $state(loadPersistedPersonId());
|
let initialSelectedPersonId = $state(loadPersistedPersonId());
|
||||||
let jobStatus = $state('idle');
|
let jobStatus = $state('idle');
|
||||||
let progress = $state({ completed: 0, total: 0, message: '' });
|
let progress = $state({ completed: 0, total: 0, message: '' });
|
||||||
let pollInterval = $state(null);
|
let ws = $state(null);
|
||||||
|
let wsReconnectTimeout = $state(null);
|
||||||
|
|
||||||
// View state management for gallery
|
// View state management for gallery
|
||||||
let currentView = $state('main'); // 'main' | 'gallery'
|
let currentView = $state('main'); // 'main' | 'gallery'
|
||||||
|
|
@ -130,66 +131,82 @@
|
||||||
function handleJobUpdate(data) {
|
function handleJobUpdate(data) {
|
||||||
jobStatus = data.status;
|
jobStatus = data.status;
|
||||||
progress = data;
|
progress = data;
|
||||||
|
|
||||||
// Start polling if job just started
|
|
||||||
if (data.status === 'running' || data.status === 'compiling_video') {
|
|
||||||
startPolling();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkAndPollProgress() {
|
async function checkAndPollProgress() {
|
||||||
try {
|
// Connect WebSocket if not already connected
|
||||||
const res = await fetch('/api/progress');
|
connectWebSocket();
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
// 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 (isActiveJob) {
|
|
||||||
startPolling();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to check progress:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollProgress() {
|
function connectWebSocket() {
|
||||||
try {
|
if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) {
|
||||||
const res = await fetch('/api/progress');
|
return; // Already connected or connecting
|
||||||
const data = await res.json();
|
}
|
||||||
|
|
||||||
const previousStatus = jobStatus;
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
jobStatus = data.status;
|
const wsUrl = `${protocol}//${window.location.host}/api/ws`;
|
||||||
progress = data;
|
|
||||||
|
|
||||||
// Stop polling when job completes
|
ws = new WebSocket(wsUrl);
|
||||||
if (data.status === 'completed' || data.status === 'cancelled' || data.status === 'error' || data.status === 'idle') {
|
|
||||||
stopPolling();
|
ws.onopen = () => {
|
||||||
// Refresh output folders when job finishes (was running before)
|
console.log('WebSocket connected');
|
||||||
if (previousStatus === 'running' || previousStatus === 'compiling_video' || previousStatus === 'cancelling') {
|
};
|
||||||
outputFolderRefreshKey++;
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
const previousStatus = jobStatus;
|
||||||
|
|
||||||
|
// Only restore status if a job is actively running, or if we're not in idle state
|
||||||
|
const isActiveJob = data.status === 'running' || data.status === 'compiling_video' || data.status === 'cancelling';
|
||||||
|
if (isActiveJob || jobStatus !== 'idle') {
|
||||||
|
jobStatus = data.status;
|
||||||
|
progress = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh output folders when job finishes (was running before)
|
||||||
|
if (data.status === 'completed' || data.status === 'cancelled' || data.status === 'error') {
|
||||||
|
if (previousStatus === 'running' || previousStatus === 'compiling_video' || previousStatus === 'cancelling') {
|
||||||
|
outputFolderRefreshKey++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse WebSocket message:', e);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
};
|
||||||
console.error('Poll failed:', e);
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('WebSocket disconnected');
|
||||||
|
ws = null;
|
||||||
|
// Reconnect if connection was established (connectionOk is true)
|
||||||
|
if (connectionOk) {
|
||||||
|
scheduleReconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (wsReconnectTimeout) return; // Already scheduled
|
||||||
|
wsReconnectTimeout = setTimeout(() => {
|
||||||
|
wsReconnectTimeout = null;
|
||||||
|
if (connectionOk) {
|
||||||
|
connectWebSocket();
|
||||||
|
}
|
||||||
|
}, WS_RECONNECT_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectWebSocket() {
|
||||||
|
if (wsReconnectTimeout) {
|
||||||
|
clearTimeout(wsReconnectTimeout);
|
||||||
|
wsReconnectTimeout = null;
|
||||||
}
|
}
|
||||||
}
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
function startPolling() {
|
ws = null;
|
||||||
if (pollInterval) return; // Already polling
|
|
||||||
pollInterval = setInterval(pollProgress, POLL_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPolling() {
|
|
||||||
if (pollInterval) {
|
|
||||||
clearInterval(pollInterval);
|
|
||||||
pollInterval = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -198,7 +215,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
stopPolling();
|
disconnectWebSocket();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.ok && data.success) {
|
if (res.ok && data.success) {
|
||||||
// Notify parent to start polling
|
// Notify parent of job start
|
||||||
onupdate?.({
|
onupdate?.({
|
||||||
status: 'running',
|
status: 'running',
|
||||||
completed: 0,
|
completed: 0,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ mod health;
|
||||||
mod output;
|
mod output;
|
||||||
mod people;
|
mod people;
|
||||||
mod processing;
|
mod processing;
|
||||||
|
mod ws;
|
||||||
|
|
||||||
use crate::web::state::AppState;
|
use crate::web::state::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
|
@ -34,6 +35,7 @@ use output::{
|
||||||
};
|
};
|
||||||
use people::{get_people, get_person_asset_count, get_person_thumbnail};
|
use people::{get_people, get_person_asset_count, get_person_thumbnail};
|
||||||
use processing::{cancel_processing, get_progress, start_processing};
|
use processing::{cancel_processing, get_progress, start_processing};
|
||||||
|
use ws::ws_handler;
|
||||||
|
|
||||||
// Re-export types that may be needed by other modules
|
// Re-export types that may be needed by other modules
|
||||||
pub use config::ConfigResponse;
|
pub use config::ConfigResponse;
|
||||||
|
|
@ -72,6 +74,7 @@ pub fn create_router(state: AppState) -> Router {
|
||||||
get(get_person_asset_count),
|
get(get_person_asset_count),
|
||||||
)
|
)
|
||||||
.route("/api/progress", get(get_progress))
|
.route("/api/progress", get(get_progress))
|
||||||
|
.route("/api/ws", get(ws_handler))
|
||||||
.route("/api/start", post(start_processing))
|
.route("/api/start", post(start_processing))
|
||||||
.route("/api/cancel", post(cancel_processing))
|
.route("/api/cancel", post(cancel_processing))
|
||||||
.route("/api/output", get(list_output_folders))
|
.route("/api/output", get(list_output_folders))
|
||||||
|
|
|
||||||
100
src/web/handlers/ws.rs
Normal file
100
src/web/handlers/ws.rs
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
//! WebSocket handler for real-time progress updates.
|
||||||
|
|
||||||
|
use crate::web::handlers::ProgressResponse;
|
||||||
|
use crate::web::state::{AppState, JobStatus};
|
||||||
|
use axum::{
|
||||||
|
extract::{
|
||||||
|
ws::{Message, WebSocket},
|
||||||
|
State, WebSocketUpgrade,
|
||||||
|
},
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use futures_util::{stream::StreamExt, SinkExt};
|
||||||
|
|
||||||
|
/// WebSocket upgrade handler.
|
||||||
|
pub async fn ws_handler(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
ws.on_upgrade(|socket| handle_socket(socket, state))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle an individual WebSocket connection.
|
||||||
|
async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||||
|
let (mut sender, mut receiver) = socket.split();
|
||||||
|
|
||||||
|
// Subscribe to progress updates
|
||||||
|
let mut progress_rx = state.progress_tx.subscribe();
|
||||||
|
|
||||||
|
// Send current progress immediately on connect
|
||||||
|
{
|
||||||
|
let progress = state.progress.read().await;
|
||||||
|
let response = progress_to_response(&progress);
|
||||||
|
if let Ok(json) = serde_json::to_string(&response) {
|
||||||
|
if sender.send(Message::Text(json.into())).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn a task to handle incoming messages (ping/pong, close)
|
||||||
|
let mut recv_task = tokio::spawn(async move {
|
||||||
|
while let Some(msg) = receiver.next().await {
|
||||||
|
match msg {
|
||||||
|
Ok(Message::Close(_)) => break,
|
||||||
|
Err(_) => break,
|
||||||
|
_ => {} // Ignore other messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send progress updates to the client
|
||||||
|
let mut send_task = tokio::spawn(async move {
|
||||||
|
while let Ok(progress) = progress_rx.recv().await {
|
||||||
|
let response = progress_to_response(&progress);
|
||||||
|
match serde_json::to_string(&response) {
|
||||||
|
Ok(json) => {
|
||||||
|
if sender.send(Message::Text(json.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for either task to complete
|
||||||
|
tokio::select! {
|
||||||
|
_ = &mut recv_task => {
|
||||||
|
send_task.abort();
|
||||||
|
}
|
||||||
|
_ = &mut send_task => {
|
||||||
|
recv_task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert Progress to ProgressResponse for JSON serialization.
|
||||||
|
fn progress_to_response(progress: &crate::web::state::Progress) -> ProgressResponse {
|
||||||
|
use crate::web::handlers::SkipStatsResponse;
|
||||||
|
|
||||||
|
let status_str = match &progress.status {
|
||||||
|
JobStatus::Idle => "idle",
|
||||||
|
JobStatus::Running => "running",
|
||||||
|
JobStatus::Cancelling => "cancelling",
|
||||||
|
JobStatus::CompilingVideo => "compiling_video",
|
||||||
|
JobStatus::Completed => "completed",
|
||||||
|
JobStatus::Cancelled => "cancelled",
|
||||||
|
JobStatus::Error(_) => "error",
|
||||||
|
};
|
||||||
|
|
||||||
|
ProgressResponse {
|
||||||
|
status: status_str.to_string(),
|
||||||
|
completed: progress.completed,
|
||||||
|
total: progress.total,
|
||||||
|
message: progress.message.clone(),
|
||||||
|
skip_stats: SkipStatsResponse::from(&progress.skip_stats),
|
||||||
|
person_id: progress.person_id.clone(),
|
||||||
|
person_name: progress.person_name.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue