@@ -389,3 +510,148 @@ function DangerBtn({ onClick, children }) {
);
}
+
+function SyncSourceHeader({ label }) {
+ return (
+
+ {label}
+
+ );
+}
+
+function ToggleSetting({ label, value, onChange }) {
+ return (
+
+ {label}
+ onChange(!value)}
+ style={{
+ width: 36, height: 20, borderRadius: 10, flexShrink: 0,
+ background: value ? '#1e3a5f' : '#1a1a1a',
+ border: '1px solid ' + (value ? '#2a5a9f' : '#2a2a2a'),
+ cursor: 'pointer', padding: 0, position: 'relative',
+ }}
+ >
+
+
+
+ );
+}
+
+function TextSetting({ label, value, placeholder, type = 'text', onChange }) {
+ const [local, setLocal] = useState(value ?? '');
+ // Sync if parent value changes (e.g. after save)
+ useState(() => setLocal(value ?? ''));
+
+ return (
+
+ {label}
+ setLocal(e.target.value)}
+ onBlur={() => onChange(local)}
+ onKeyDown={e => e.key === 'Enter' && onChange(local)}
+ style={{
+ width: 170, background: '#1a1a1a', border: '1px solid #2a2a2a',
+ borderRadius: 5, color: '#ccc', fontSize: 11,
+ padding: '4px 8px', outline: 'none', fontFamily: 'monospace',
+ }}
+ />
+
+ );
+}
+
+function SyncControls({ job, error, onSync }) {
+ const [showLog, setShowLog] = useState(false);
+
+ // Auto-show log while running
+ const wasRunning = job.running;
+ if (wasRunning && !showLog) setShowLog(true);
+
+ const lastRunLabel = job.startedAt
+ ? new Date(job.startedAt).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
+ : null;
+
+ return (
+
+
+
+ {job.running ? 'Syncing…' : 'Sync Now'}
+
+
+ {job.running && (
+
+ )}
+
+ {lastRunLabel && !job.running && (
+
+ Last: {lastRunLabel}
+ {job.exitCode !== null && (
+
+ {job.exitCode === 0 ? '✓' : `✗ (${job.exitCode})`}
+
+ )}
+
+ )}
+
+ {job.lines.length > 0 && (
+ setShowLog(s => !s)}
+ style={{
+ background: 'none', border: 'none', color: '#3a3a3a',
+ cursor: 'pointer', fontSize: 11, marginLeft: 'auto', padding: 0,
+ }}
+ >
+ {showLog ? 'hide log' : 'show log'}
+
+ )}
+
+
+ {error && (
+
{error}
+ )}
+
+ {showLog && job.lines.length > 0 && (
+
+ {job.lines.slice(-60).map((line, i) => (
+
+ {line}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/scripts/amazon_sync.py b/scripts/amazon_sync.py
new file mode 100644
index 0000000..965ffe2
--- /dev/null
+++ b/scripts/amazon_sync.py
@@ -0,0 +1,134 @@
+#!/usr/bin/env python3
+"""
+Download new photos from Amazon Photos using browser-exported cookies.
+
+Usage:
+ amazon_sync.py
+
+cookies.txt must be in Netscape/Mozilla format. Export it from your browser
+using an extension like "Get cookies.txt LOCALLY" while logged into amazon.com.
+"""
+
+import sys
+import json
+import time
+import pathlib
+import http.cookiejar
+import urllib.request
+import urllib.parse
+import urllib.error
+
+HEADERS = {
+ "User-Agent": (
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/124.0.0.0 Safari/537.36"
+ ),
+ "Accept": "application/json",
+}
+
+
+def build_opener(cookie_file):
+ jar = http.cookiejar.MozillaCookieJar()
+ try:
+ jar.load(cookie_file, ignore_discard=True, ignore_expires=True)
+ except FileNotFoundError:
+ print(f"Error: cookie file not found: {cookie_file}", file=sys.stderr, flush=True)
+ sys.exit(1)
+ return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
+
+
+def api_get(opener, url):
+ req = urllib.request.Request(url, headers=HEADERS)
+ with opener.open(req, timeout=30) as r:
+ return json.loads(r.read().decode())
+
+
+def list_photos(opener, offset=0, limit=200):
+ params = urllib.parse.urlencode({
+ "filters": "kind:PHOTOS and contentProperties.contentType:(image*)",
+ "sort": '["modifiedDate DESC"]',
+ "limit": limit,
+ "offset": offset,
+ "asset": "ALL",
+ "tempLink": "false",
+ })
+ return api_get(opener, f"https://www.amazon.com/drive/v1/nodes?{params}")
+
+
+def download_node(opener, node_id, dest_path):
+ url = f"https://www.amazon.com/drive/v1/nodes/{node_id}/content"
+ req = urllib.request.Request(url, headers=HEADERS)
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
+ with opener.open(req, timeout=120) as r, open(dest_path, "wb") as f:
+ while True:
+ chunk = r.read(65536)
+ if not chunk:
+ break
+ f.write(chunk)
+
+
+def main():
+ if len(sys.argv) < 3:
+ print("Usage: amazon_sync.py ", file=sys.stderr, flush=True)
+ sys.exit(1)
+
+ cookie_file = sys.argv[1]
+ dest_dir = pathlib.Path(sys.argv[2])
+ dest_dir.mkdir(parents=True, exist_ok=True)
+
+ opener = build_opener(cookie_file)
+ downloaded = 0
+ skipped = 0
+ offset = 0
+
+ print(f"Destination: {dest_dir}", flush=True)
+
+ while True:
+ print(f"Fetching photo list (offset={offset})…", flush=True)
+ try:
+ data = list_photos(opener, offset=offset)
+ except urllib.error.HTTPError as e:
+ if e.code == 401:
+ print(
+ "Authentication failed. Re-export your cookies from a logged-in amazon.com session.",
+ file=sys.stderr, flush=True,
+ )
+ else:
+ print(f"HTTP {e.code}: {e.reason}", file=sys.stderr, flush=True)
+ sys.exit(1)
+ except Exception as e:
+ print(f"Error: {e}", file=sys.stderr, flush=True)
+ sys.exit(1)
+
+ nodes = data.get("data", [])
+ total_count = data.get("count", 0)
+
+ if not nodes:
+ break
+
+ for node in nodes:
+ name = node.get("name") or f"{node['id']}.jpg"
+ dest = dest_dir / name
+ if dest.exists():
+ skipped += 1
+ continue
+ print(f" Downloading {name}", flush=True)
+ try:
+ download_node(opener, node["id"], dest)
+ downloaded += 1
+ time.sleep(0.1)
+ except Exception as e:
+ print(f" Error: {e}", file=sys.stderr, flush=True)
+
+ offset += len(nodes)
+ if offset >= total_count:
+ break
+
+ time.sleep(0.5)
+
+ print(f"\nDone — {downloaded} downloaded, {skipped} already present.", flush=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/server/index.js b/server/index.js
index a99587a..4f37560 100644
--- a/server/index.js
+++ b/server/index.js
@@ -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());
});
diff --git a/worker/db.js b/worker/db.js
index daf258e..51d2e72 100644
--- a/worker/db.js
+++ b/worker/db.js
@@ -3,13 +3,26 @@ import path from 'path';
import fs from 'fs';
const SETTING_DEFAULTS = {
- thumb_size: '400',
- thumb_quality: '80',
- preview_size: '1600',
- preview_quality: '85',
- dedupe_threshold: '10',
+ thumb_size: '400',
+ thumb_quality: '80',
+ preview_size: '1600',
+ preview_quality: '85',
+ dedupe_threshold: '10',
+ // iCloud Photos sync
+ icloud_enabled: '0',
+ icloud_apple_id: '',
+ icloud_password: '',
+ icloud_dest: '/photos/icloud',
+ icloud_cookie_dir: '/data/icloud-cookies',
+ // Amazon Photos sync
+ amazon_enabled: '0',
+ amazon_cookie_file: '',
+ amazon_dest: '/photos/amazon',
};
+const NUMERIC_SETTINGS = new Set(['thumb_size', 'thumb_quality', 'preview_size', 'preview_quality', 'dedupe_threshold']);
+const BOOL_SETTINGS = new Set(['icloud_enabled', 'amazon_enabled']);
+
export function openDb(dbPath) {
fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
@@ -218,8 +231,8 @@ export function openDb(dbPath) {
const rows = s.settingsAll.all();
const out = { ...SETTING_DEFAULTS };
for (const { key, value } of rows) out[key] = value;
- // Parse numerics
- for (const k of Object.keys(SETTING_DEFAULTS)) out[k] = parseInt(out[k], 10);
+ for (const k of NUMERIC_SETTINGS) out[k] = parseInt(out[k], 10);
+ for (const k of BOOL_SETTINGS) out[k] = out[k] === '1' || out[k] === true;
return out;
},
setSetting: (key, value) => s.settingsSet.run(key, String(value)),