312 lines
6.7 KiB
TypeScript
312 lines
6.7 KiB
TypeScript
import { browser } from '$app/environment';
|
|
import { api } from '$lib/api/client';
|
|
import { libraryStore } from '$lib/stores/library';
|
|
|
|
export type SyncStatus = {
|
|
is_syncing: boolean;
|
|
phase: string | null;
|
|
total_items: number;
|
|
processed_items: number;
|
|
progress_percent: number;
|
|
current_item: string | null;
|
|
error_message: string | null;
|
|
total_artists: number;
|
|
processed_artists: number;
|
|
total_albums: number;
|
|
processed_albums: number;
|
|
};
|
|
|
|
const PHASE_LABELS: Record<string, string> = {
|
|
artists: 'Artist Images',
|
|
discovery: 'Artist Discovery',
|
|
albums: 'Album Data',
|
|
audiodb_prewarm: 'AudioDB Images'
|
|
};
|
|
|
|
const PHASE_ORDER = ['artists', 'discovery', 'albums', 'audiodb_prewarm'];
|
|
|
|
const EMPTY_STATUS: SyncStatus = {
|
|
is_syncing: false,
|
|
phase: null,
|
|
total_items: 0,
|
|
processed_items: 0,
|
|
progress_percent: 0,
|
|
current_item: null,
|
|
error_message: null,
|
|
total_artists: 0,
|
|
processed_artists: 0,
|
|
total_albums: 0,
|
|
processed_albums: 0
|
|
};
|
|
|
|
const MAX_RECONNECT_ATTEMPTS = 5;
|
|
const POLL_ACTIVE_MS = 1500;
|
|
const POLL_IDLE_MS = 5000;
|
|
const AUTO_HIDE_SUCCESS_MS = 4000;
|
|
const AUTO_HIDE_ERROR_MS = 6000;
|
|
|
|
function createSyncStatusStore() {
|
|
let status = $state<SyncStatus>({ ...EMPTY_STATUS });
|
|
let isDismissed = $state(false);
|
|
let isMinimized = $state(false);
|
|
let showIndicator = $state(false);
|
|
let connectionMode = $state<'sse' | 'polling'>('sse');
|
|
|
|
let eventSource: EventSource | null = null;
|
|
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
|
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let reconnectAttempts = 0;
|
|
let statusVersion = 0;
|
|
let connected = false;
|
|
|
|
function clearAllTimers(): void {
|
|
if (pollInterval) {
|
|
clearInterval(pollInterval);
|
|
pollInterval = null;
|
|
}
|
|
if (hideTimeout) {
|
|
clearTimeout(hideTimeout);
|
|
hideTimeout = null;
|
|
}
|
|
if (reconnectTimeout) {
|
|
clearTimeout(reconnectTimeout);
|
|
reconnectTimeout = null;
|
|
}
|
|
}
|
|
|
|
function applyStatus(newStatus: SyncStatus): void {
|
|
statusVersion++;
|
|
const wasSyncing = status.is_syncing;
|
|
status = newStatus;
|
|
|
|
if (newStatus.is_syncing && !wasSyncing) {
|
|
isDismissed = false;
|
|
isMinimized = false;
|
|
}
|
|
|
|
if (wasSyncing && !newStatus.is_syncing && !newStatus.error_message && browser) {
|
|
libraryStore.refresh();
|
|
}
|
|
|
|
handleStatusUpdate(newStatus);
|
|
|
|
if (connectionMode === 'polling' && wasSyncing !== newStatus.is_syncing) {
|
|
schedulePoll();
|
|
}
|
|
}
|
|
|
|
function handleStatusUpdate(newStatus: SyncStatus): void {
|
|
if (newStatus.is_syncing) {
|
|
if (hideTimeout) {
|
|
clearTimeout(hideTimeout);
|
|
hideTimeout = null;
|
|
}
|
|
if (!isDismissed) {
|
|
showIndicator = true;
|
|
}
|
|
} else if (newStatus.error_message) {
|
|
showIndicator = true;
|
|
if (!hideTimeout) {
|
|
hideTimeout = setTimeout(() => {
|
|
showIndicator = false;
|
|
hideTimeout = null;
|
|
}, AUTO_HIDE_ERROR_MS);
|
|
}
|
|
} else if (showIndicator && !hideTimeout) {
|
|
hideTimeout = setTimeout(() => {
|
|
showIndicator = false;
|
|
hideTimeout = null;
|
|
}, AUTO_HIDE_SUCCESS_MS);
|
|
}
|
|
}
|
|
|
|
function connectSSE(): void {
|
|
if (!browser || document.hidden) return;
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
eventSource = null;
|
|
}
|
|
|
|
eventSource = new EventSource('/api/v1/cache/sync/stream');
|
|
|
|
eventSource.onopen = () => {
|
|
connectionMode = 'sse';
|
|
reconnectAttempts = 0;
|
|
if (pollInterval) {
|
|
clearInterval(pollInterval);
|
|
pollInterval = null;
|
|
}
|
|
};
|
|
|
|
eventSource.onmessage = (event) => {
|
|
try {
|
|
applyStatus(JSON.parse(event.data));
|
|
} catch {
|
|
// ignore malformed messages
|
|
}
|
|
};
|
|
|
|
eventSource.onerror = () => {
|
|
eventSource?.close();
|
|
eventSource = null;
|
|
reconnectAttempts++;
|
|
|
|
if (reconnectTimeout) {
|
|
clearTimeout(reconnectTimeout);
|
|
reconnectTimeout = null;
|
|
}
|
|
|
|
if (status.is_syncing && !pollInterval) {
|
|
startPolling();
|
|
}
|
|
|
|
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
|
reconnectTimeout = setTimeout(() => {
|
|
reconnectTimeout = null;
|
|
if (connected && !document.hidden) connectSSE();
|
|
}, delay);
|
|
} else {
|
|
connectionMode = 'polling';
|
|
if (!pollInterval) startPolling();
|
|
}
|
|
};
|
|
}
|
|
|
|
async function fetchStatus(): Promise<void> {
|
|
try {
|
|
const data = await api.global.get<SyncStatus>('/api/v1/cache/sync/status');
|
|
applyStatus(data);
|
|
} catch {
|
|
// ignore fetch errors
|
|
}
|
|
}
|
|
|
|
function startPolling(): void {
|
|
if (pollInterval) {
|
|
clearInterval(pollInterval);
|
|
pollInterval = null;
|
|
}
|
|
void fetchStatus();
|
|
schedulePoll();
|
|
}
|
|
|
|
function schedulePoll(): void {
|
|
if (pollInterval) {
|
|
clearInterval(pollInterval);
|
|
}
|
|
pollInterval = setInterval(
|
|
() => void fetchStatus(),
|
|
status.is_syncing ? POLL_ACTIVE_MS : POLL_IDLE_MS
|
|
);
|
|
}
|
|
|
|
function handleVisibilityChange(): void {
|
|
if (!connected) return;
|
|
if (document.hidden) {
|
|
if (reconnectTimeout) {
|
|
clearTimeout(reconnectTimeout);
|
|
reconnectTimeout = null;
|
|
}
|
|
eventSource?.close();
|
|
eventSource = null;
|
|
if (pollInterval) {
|
|
clearInterval(pollInterval);
|
|
pollInterval = null;
|
|
}
|
|
} else {
|
|
if (connectionMode === 'sse') {
|
|
reconnectAttempts = 0;
|
|
connectSSE();
|
|
} else {
|
|
startPolling();
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
get status() {
|
|
return status;
|
|
},
|
|
get isActive() {
|
|
return status.is_syncing;
|
|
},
|
|
get phase() {
|
|
return status.phase;
|
|
},
|
|
get progress() {
|
|
return status.progress_percent;
|
|
},
|
|
get currentItem() {
|
|
return status.current_item;
|
|
},
|
|
get error() {
|
|
return status.error_message;
|
|
},
|
|
get totalItems() {
|
|
return status.total_items;
|
|
},
|
|
get processedItems() {
|
|
return status.processed_items;
|
|
},
|
|
get isDismissed() {
|
|
return isDismissed;
|
|
},
|
|
get showIndicator() {
|
|
return showIndicator && !isDismissed;
|
|
},
|
|
get isMinimized() {
|
|
return isMinimized;
|
|
},
|
|
get connectionMode() {
|
|
return connectionMode;
|
|
},
|
|
get phaseLabel() {
|
|
return status.phase ? (PHASE_LABELS[status.phase] ?? 'Syncing') : 'Library';
|
|
},
|
|
get phaseNumber() {
|
|
if (!status.phase) return 0;
|
|
const idx = PHASE_ORDER.indexOf(status.phase);
|
|
return idx >= 0 ? idx + 1 : 0;
|
|
},
|
|
get totalPhases() {
|
|
return PHASE_ORDER.length;
|
|
},
|
|
|
|
connect(): void {
|
|
if (!browser || connected) return;
|
|
connected = true;
|
|
connectSSE();
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
},
|
|
|
|
disconnect(): void {
|
|
if (!browser) return;
|
|
connected = false;
|
|
eventSource?.close();
|
|
eventSource = null;
|
|
clearAllTimers();
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
},
|
|
|
|
dismiss(): void {
|
|
isDismissed = true;
|
|
},
|
|
|
|
minimize(): void {
|
|
isMinimized = true;
|
|
},
|
|
|
|
expand(): void {
|
|
isMinimized = false;
|
|
},
|
|
|
|
checkStatus(): void {
|
|
void fetchStatus();
|
|
}
|
|
};
|
|
}
|
|
|
|
export const syncStatus = createSyncStatusStore();
|