feat: photo sync (iCloud + Amazon import), larger gear icon, Luma branding
- Settings: iCloud Photos section with icloudpd-backed sync — Apple ID, password, download path, cookie dir, live log output via SSE - Settings: Amazon Photos one-time import — path to extracted data export, Add & Index button wires it directly into Watch Folders - scripts/amazon_sync.py: stdlib-only downloader using Netscape cookie file - server: POST /api/sync/icloud, /api/sync/amazon, GET /api/sync/stream (SSE log streaming matching ingest stream pattern) - db: icloud/amazon setting keys; getSettings handles bool + string fields - Gear icon: fontSize 16→20, button 38×38, color #555→#888 - favicon.svg: 6-blade aperture icon in Luma blue - Browser tab title: shpm → Luma - Dockerfile: installs icloudpd via pip3, copies scripts/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
5ff7f38b85
commit
4ff5a6c8ca
9 files changed
+569
-17
No files matched your search
+112
-4
@@ -3,6 +3,7 @@ import express from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { spawn } from 'child_process';
|
||||
import { openDb } from '../worker/db.js';
|
||||
import { scan, getGroups } from './dedupe.js';
|
||||
|
||||
@@ -17,6 +18,57 @@ const posterDirAbs = path.resolve(POSTER_DIR);
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// ── Photo Sync state ──────────────────────────────────────────────────────────
|
||||
|
||||
const syncJobs = {
|
||||
icloud: { running: false, lines: [], exitCode: null, startedAt: null, error: null },
|
||||
amazon: { running: false, lines: [], exitCode: null, startedAt: null, error: null },
|
||||
};
|
||||
const syncClients = new Set();
|
||||
|
||||
function pushSyncUpdate() {
|
||||
const payload = JSON.stringify(syncJobs);
|
||||
for (const res of syncClients) res.write(`data: ${payload}\n\n`);
|
||||
}
|
||||
|
||||
function runSync(source, command, args) {
|
||||
const job = syncJobs[source];
|
||||
if (job.running) return false;
|
||||
job.running = true;
|
||||
job.lines = [];
|
||||
job.exitCode = null;
|
||||
job.startedAt = new Date().toISOString();
|
||||
job.error = null;
|
||||
pushSyncUpdate();
|
||||
|
||||
const proc = spawn(command, args, { stdio: 'pipe' });
|
||||
|
||||
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);
|
||||
pushSyncUpdate();
|
||||
};
|
||||
proc.stdout.on('data', onData);
|
||||
proc.stderr.on('data', onData);
|
||||
|
||||
proc.on('close', (code) => {
|
||||
job.running = false;
|
||||
job.exitCode = code;
|
||||
if (code !== 0) job.error = `Exited with code ${code}`;
|
||||
pushSyncUpdate();
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
job.running = false;
|
||||
job.error = err.message;
|
||||
job.lines.push(`Error: ${err.message}`);
|
||||
pushSyncUpdate();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function posterUrl(storedPath) {
|
||||
@@ -285,16 +337,72 @@ app.post('/api/duplicates/dismiss', (req, res) => {
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
// ── Photo Sync ────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/sync/stream', (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
res.write('retry: 3000\n\n');
|
||||
res.write(`data: ${JSON.stringify(syncJobs)}\n\n`);
|
||||
syncClients.add(res);
|
||||
req.on('close', () => syncClients.delete(res));
|
||||
});
|
||||
|
||||
app.post('/api/sync/icloud', (_req, res) => {
|
||||
const s = db.getSettings();
|
||||
if (!s.icloud_apple_id) return badRequest(res, 'Apple ID not configured');
|
||||
|
||||
const args = [
|
||||
'--username', s.icloud_apple_id,
|
||||
'--directory', s.icloud_dest,
|
||||
'--cookie-directory', s.icloud_cookie_dir,
|
||||
'--no-progress',
|
||||
];
|
||||
if (s.icloud_password) args.push('--password', s.icloud_password);
|
||||
|
||||
if (!runSync('icloud', 'icloudpd', args)) {
|
||||
return res.status(409).json({ error: 'Sync already running' });
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/sync/amazon', (_req, res) => {
|
||||
const s = db.getSettings();
|
||||
if (!s.amazon_cookie_file) return badRequest(res, 'Cookie file not configured');
|
||||
|
||||
const scriptPath = path.join(path.dirname(__dirname), 'scripts', 'amazon_sync.py');
|
||||
if (!fs.existsSync(scriptPath)) {
|
||||
return res.status(500).json({ error: 'amazon_sync.py not found in /app/scripts/' });
|
||||
}
|
||||
|
||||
if (!runSync('amazon', 'python3', [scriptPath, s.amazon_cookie_file, s.amazon_dest])) {
|
||||
return res.status(409).json({ error: 'Sync already running' });
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/settings', (_req, res) => res.json(db.getSettings()));
|
||||
|
||||
const NUMERIC_SETTING_KEYS = new Set(['thumb_size', 'thumb_quality', 'preview_size', 'preview_quality', 'dedupe_threshold']);
|
||||
const STRING_SETTING_KEYS = new Set(['icloud_apple_id', 'icloud_password', 'icloud_dest', 'icloud_cookie_dir', 'amazon_cookie_file', 'amazon_dest']);
|
||||
const BOOL_SETTING_KEYS = new Set(['icloud_enabled', 'amazon_enabled']);
|
||||
|
||||
app.patch('/api/settings', (req, res) => {
|
||||
const allowed = new Set(['thumb_size', 'thumb_quality', 'preview_size', 'preview_quality', 'dedupe_threshold']);
|
||||
for (const [key, value] of Object.entries(req.body)) {
|
||||
if (!allowed.has(key)) continue;
|
||||
const n = parseInt(value, 10);
|
||||
if (!isNaN(n)) db.setSetting(key, n);
|
||||
if (NUMERIC_SETTING_KEYS.has(key)) {
|
||||
const n = parseInt(value, 10);
|
||||
if (!isNaN(n)) db.setSetting(key, n);
|
||||
} else if (STRING_SETTING_KEYS.has(key)) {
|
||||
db.setSetting(key, String(value ?? ''));
|
||||
} else if (BOOL_SETTING_KEYS.has(key)) {
|
||||
db.setSetting(key, value ? '1' : '0');
|
||||
}
|
||||
}
|
||||
res.json(db.getSettings());
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user