Files
immich-sync/web/app.js
T

297 lines
10 KiB
JavaScript

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();