feat: sync all iCloud libraries (personal + shared + shared albums)

Auto-discovers libraries via --list-libraries then runs icloudpd once
per library, each into its own subfolder under icloud_dest. Covers
PrimarySync, SharedSync-*, and SharedCollection-* automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-18 10:24:29 -04:00
1 parent a072090017
commit e14919fe17
1 file changed
+88 -21
+88 -21
View File
@@ -31,6 +31,19 @@ function pushSyncUpdate() {
for (const res of syncClients) res.write(`data: ${payload}\n\n`); for (const res of syncClients) res.write(`data: ${payload}\n\n`);
} }
function spawnAsync(command, args, onLine) {
return new Promise((resolve, reject) => {
const proc = spawn(command, args, { stdio: 'pipe' });
const onData = (chunk) => {
chunk.toString().split('\n').filter(Boolean).forEach(onLine);
};
proc.stdout.on('data', onData);
proc.stderr.on('data', onData);
proc.on('close', resolve);
proc.on('error', reject);
});
}
function runSync(source, command, args) { function runSync(source, command, args) {
const job = syncJobs[source]; const job = syncJobs[source];
if (job.running) return false; if (job.running) return false;
@@ -41,34 +54,81 @@ function runSync(source, command, args) {
job.error = null; job.error = null;
pushSyncUpdate(); pushSyncUpdate();
const proc = spawn(command, args, { stdio: 'pipe' }); const addLine = (line) => {
job.lines.push(line);
const onData = (chunk) => {
const newLines = chunk.toString().split('\n').filter(Boolean);
job.lines.push(...newLines);
if (job.lines.length > 500) job.lines = job.lines.slice(-500); if (job.lines.length > 500) job.lines = job.lines.slice(-500);
pushSyncUpdate(); pushSyncUpdate();
}; };
proc.stdout.on('data', onData);
proc.stderr.on('data', onData);
proc.on('close', (code) => { spawnAsync(command, args, addLine)
.then(code => {
job.running = false; job.running = false;
job.exitCode = code; job.exitCode = code;
if (code !== 0) job.error = `Exited with code ${code}`; if (code !== 0) job.error = `Exited with code ${code}`;
pushSyncUpdate(); pushSyncUpdate();
}); })
.catch(err => {
proc.on('error', (err) => {
job.running = false; job.running = false;
job.error = err.message; job.error = err.message;
job.lines.push(`Error: ${err.message}`); addLine(`Error: ${err.message}`);
pushSyncUpdate(); pushSyncUpdate();
}); });
return true; return true;
} }
async function runICloudSync(s) {
const job = syncJobs.icloud;
const addLine = (line) => {
job.lines.push(line);
if (job.lines.length > 500) job.lines = job.lines.slice(-500);
pushSyncUpdate();
};
const baseArgs = ['--cookie-directory', s.icloud_cookie_dir, '--username', s.icloud_apple_id];
if (s.icloud_password) baseArgs.push('--password', s.icloud_password);
// Discover libraries
addLine('Discovering libraries…');
const discoveryLines = [];
await spawnAsync('icloudpd', [...baseArgs, '--list-libraries'], (line) => {
addLine(line);
discoveryLines.push(line);
});
const libraries = discoveryLines
.map(l => l.trim())
.filter(l => /^[A-Za-z][A-Za-z0-9-]*$/.test(l));
if (libraries.length === 0) {
job.running = false;
job.exitCode = 1;
job.error = 'No libraries found';
pushSyncUpdate();
return;
}
addLine(`Found ${libraries.length} librar${libraries.length === 1 ? 'y' : 'ies'}: ${libraries.join(', ')}`);
let exitCode = 0;
for (const lib of libraries) {
const dest = path.join(s.icloud_dest, lib);
addLine(`\n── Syncing ${lib} → ${dest}`);
const code = await spawnAsync('icloudpd', [
...baseArgs,
'--directory', dest,
'--library', lib,
], addLine);
if (code !== 0) exitCode = code;
}
job.running = false;
job.exitCode = exitCode;
if (exitCode !== 0) job.error = `Exited with code ${exitCode}`;
pushSyncUpdate();
}
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
function posterUrl(storedPath) { function posterUrl(storedPath) {
@@ -356,16 +416,23 @@ app.post('/api/sync/icloud', (_req, res) => {
const s = db.getSettings(); const s = db.getSettings();
if (!s.icloud_apple_id) return badRequest(res, 'Apple ID not configured'); if (!s.icloud_apple_id) return badRequest(res, 'Apple ID not configured');
const args = [ const job = syncJobs.icloud;
'--username', s.icloud_apple_id, if (job.running) return res.status(409).json({ error: 'Sync already running' });
'--directory', s.icloud_dest,
'--cookie-directory', s.icloud_cookie_dir, job.running = true;
]; job.lines = [];
if (s.icloud_password) args.push('--password', s.icloud_password); job.exitCode = null;
job.startedAt = new Date().toISOString();
job.error = null;
pushSyncUpdate();
runICloudSync(s).catch(err => {
job.running = false;
job.error = err.message;
job.lines.push(`Error: ${err.message}`);
pushSyncUpdate();
});
if (!runSync('icloud', 'icloudpd', args)) {
return res.status(409).json({ error: 'Sync already running' });
}
res.json({ ok: true }); res.json({ ok: true });
}); });