const $ = (selector) => document.querySelector(selector); const $$ = (selector) => [...document.querySelectorAll(selector)]; const ui = { connection: $('#connection'), inspect: $('#inspect'), message: $('#inspectionMessage'), options: $('#syncOptions'), activity: $('#activity'), activityTitle: $('#activityTitle'), abort: $('#abort'), progressBar: $('#progressBar'), progressText: $('#progressText'), progressCount: $('#progressCount'), events: $('#events'), }; let socket; let jobType; let jobCompleted = false; let abortRequested = false; let syncRunning = false; function normalizedShareUrl(value) { try { const url = new URL(value.trim()); if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password) return undefined; const marker = url.pathname.lastIndexOf('/share/'); if (marker < 0) return undefined; const credential = url.pathname.slice(marker + 7).replace(/\/+$/, ''); if (!credential || credential.includes('/')) return undefined; url.search = ''; url.hash = ''; return url.toString(); } catch { return undefined; } } function updateShareFragment() { const left = normalizedShareUrl($('#leftUrl').value); const right = normalizedShareUrl($('#rightUrl').value); if (!left || !right) { if (location.hash) history.replaceState(null, '', `${location.pathname}${location.search}`); return; } const fragment = new URLSearchParams({ left, right }); history.replaceState(null, '', `${location.pathname}${location.search}#${fragment}`); } function restoreShareFragment() { const fragment = new URLSearchParams(location.hash.slice(1)); const left = normalizedShareUrl(fragment.get('left') || ''); const right = normalizedShareUrl(fragment.get('right') || ''); if (!left || !right) return false; $('#leftUrl').value = left; $('#rightUrl').value = right; return true; } function openJob(request) { if (socket && socket.readyState !== WebSocket.CLOSED) { ui.message.textContent = 'Please wait for the current job to finish.'; return false; } const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; const jobSocket = new WebSocket(`${protocol}//${location.host}/ws`); socket = jobSocket; jobType = request.type; jobCompleted = false; abortRequested = false; ui.inspect.disabled = true; updateShareFragment(); setConnection('connecting', 'Connecting'); jobSocket.addEventListener('open', () => { setConnection('connected', request.type === 'inspect' ? 'Inspecting' : 'Synchronizing'); jobSocket.send(JSON.stringify(request)); }); jobSocket.addEventListener('message', ({ data }) => { try { handle(JSON.parse(data)); } catch { addEvent('error', 'Received malformed service response'); } }); jobSocket.addEventListener('close', () => { if (socket !== jobSocket) return; socket = undefined; setConnection('idle', 'Idle'); ui.inspect.disabled = false; if (abortRequested && !jobCompleted) { syncRunning = false; jobCompleted = true; addEvent('aborted', 'Synchronization aborted'); setActivityProgress('Aborted', 0, 0); } else if (!jobCompleted && jobType === 'sync') { syncRunning = false; addEvent('error', 'Socket disconnected — the server aborted this synchronization'); setActivityProgress('Aborted', 0, 0); } else if (!jobCompleted && jobType === 'inspect') { ui.message.textContent = 'Inspection stopped before it completed.'; } jobType = undefined; }); jobSocket.addEventListener('error', () => setConnection('disconnected', 'Connection error')); return true; } function setConnection(state, text) { ui.connection.className = `connection ${state}`; ui.connection.querySelector('span').textContent = text; } function shareRequest(type) { return { type, left: { url: $('#leftUrl').value, password: $('#leftPassword').value }, right: { url: $('#rightUrl').value, password: $('#rightPassword').value }, }; } function inspect() { const leftUrl = $('#leftUrl'); const rightUrl = $('#rightUrl'); if (!leftUrl.reportValidity() || !rightUrl.reportValidity()) return; if (openJob(shareRequest('inspect'))) resetInspection(); } function resetInspection() { ui.options.classList.add('hidden'); ui.message.textContent = 'Inspecting both Immich shares…'; for (const side of ['left', 'right']) { setSide(side, 'connecting', 'Starting inspection'); $(`#${side}Details`).classList.add('hidden'); } } function setSide(side, stage, message, share) { const state = $(`#${side}State`); const card = $(`#${side}Card`); const status = $(`#${side}Status`); const labels = { idle: 'Idle', connecting: 'Connecting', authenticating: 'Password', ready: 'Ready', 'password-required': 'Password needed', error: 'Error', }; state.className = `state ${stage}`; state.textContent = labels[stage] || stage; status.textContent = message; card.classList.toggle('attention', stage === 'password-required'); card.classList.toggle('has-error', stage === 'error'); if (stage === 'password-required') $(`#${side}Password`).focus(); if (share) showShare(side, share); } function showShare(side, share) { const details = $(`#${side}Details`); details.replaceChildren( detailRow('Album', share.name), detailRow('Assets', String(share.assetCount)), detailRow('Download', share.allowDownload ? 'Allowed' : 'Blocked', share.allowDownload), detailRow('Upload', share.allowUpload ? 'Allowed' : 'Blocked', share.allowUpload), detailRow('Server', share.origin), ); details.classList.remove('hidden'); } function detailRow(label, value, positive) { const row = document.createElement('div'); const key = document.createElement('span'); const val = document.createElement('strong'); key.textContent = label; val.textContent = value; if (positive === true) val.className = 'positive'; if (positive === false) val.className = 'negative'; row.append(key, val); return row; } function showOptions(options) { for (const button of $$('#syncOptions button')) { const option = options[button.dataset.direction]; button.disabled = !option.allowed; button.title = option.reason || ''; button.querySelector('span').textContent = option.allowed ? `${option.missing} missing asset${option.missing === 1 ? '' : 's'}` : option.reason; } ui.options.classList.remove('hidden'); } function startSync(direction) { const request = shareRequest('sync'); request.direction = direction; if (!openJob(request)) return; syncRunning = true; ui.activity.classList.remove('hidden'); ui.activityTitle.textContent = direction === 'bidirectional' ? 'Bidirectional synchronization' : direction === 'left-to-right' ? 'Synchronizing A → B' : 'Synchronizing B → A'; ui.events.replaceChildren(); ui.activity.scrollIntoView({ behavior: 'smooth', block: 'start' }); } function setActivityProgress(text, current, total) { ui.progressText.textContent = text; ui.progressCount.textContent = `${current} / ${total}`; ui.progressBar.style.width = total > 0 ? `${Math.min(100, current / total * 100)}%` : '0%'; } function addEvent(kind, message, detail = '') { ui.activity.classList.remove('hidden'); const item = document.createElement('li'); item.className = kind; const time = document.createElement('time'); const copy = document.createElement('div'); const main = document.createElement('strong'); time.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); main.textContent = message; copy.append(main); if (detail) { const sub = document.createElement('span'); sub.textContent = detail; copy.append(sub); } item.append(time, copy); ui.events.prepend(item); while (ui.events.children.length > 250) ui.events.lastElementChild.remove(); } function formatBytes(value) { if (!Number.isFinite(value) || value < 0) return 'unknown size'; const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; let size = value; let unit = 0; while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit++; } return `${size.toFixed(unit ? 1 : 0)} ${units[unit]}`; } function handle(message) { switch (message.type) { case 'hello': break; case 'inspection-status': ui.message.textContent = message.message; break; case 'side-status': setSide(message.side, message.stage, message.message, message.share); break; case 'inspection': ui.message.textContent = message.message; if (message.ready) showOptions(message.options); if (jobType === 'inspect' || !message.ready) jobCompleted = true; break; case 'sync-status': { const current = message.completed || 0; const failed = message.failed || 0; setActivityProgress(message.message, current + failed, message.total || 0); addEvent(message.stage === 'complete' && failed === 0 ? 'success' : message.stage, message.message, message.stage === 'complete' ? `${current} completed · ${failed} failed` : ''); if (['complete', 'aborted'].includes(message.stage)) { syncRunning = false; jobCompleted = true; } break; } case 'asset-status': { const done = (message.completed || 0) + (message.failed || 0); setActivityProgress(`${message.stage}: ${message.fileName}`, done, message.total || 0); addEvent(message.stage, message.message, `${message.fileName} · ${message.direction}`); break; } case 'asset-progress': ui.progressText.textContent = `${message.phase}: ${message.fileName} · ${formatBytes(message.bytes)} / ${formatBytes(message.totalBytes)}`; ui.progressCount.textContent = `${message.current} / ${message.total}`; break; case 'error': ui.message.textContent = message.message; addEvent('error', message.message); syncRunning = false; jobCompleted = true; break; } } ui.inspect.addEventListener('click', inspect); for (const input of [$('#leftUrl'), $('#rightUrl')]) input.addEventListener('input', updateShareFragment); for (const input of $$('input')) input.addEventListener('keydown', (event) => { if (event.key === 'Enter') inspect(); }); for (const button of $$('#syncOptions button')) button.addEventListener('click', () => startSync(button.dataset.direction)); ui.abort.addEventListener('click', () => { if (jobType === 'sync' && socket && socket.readyState === WebSocket.OPEN && !jobCompleted) { abortRequested = true; socket.close(1000, 'User aborted synchronization'); } }); setConnection('idle', 'Idle'); if (restoreShareFragment()) inspect();