diff --git a/Dockerfile b/Dockerfile index ffb1c46..a7f1f3b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,8 @@ RUN npm run build # ── Stage 2: runtime ───────────────────────────────────────────────────────── FROM node:22-alpine -RUN apk add --no-cache python3 make g++ +RUN apk add --no-cache python3 py3-pip make g++ && \ + pip3 install icloudpd --break-system-packages WORKDIR /app @@ -24,6 +25,7 @@ RUN npm ci --omit=dev COPY --from=builder /app/dist ./dist COPY worker ./worker COPY server ./server +COPY scripts ./scripts ENV PORT=3000 \ DB_PATH=/data/library.db \ diff --git a/client/index.html b/client/index.html index 2a98c1d..75c731f 100644 --- a/client/index.html +++ b/client/index.html @@ -3,7 +3,8 @@ - shpm + + Luma
diff --git a/client/public/favicon.svg b/client/public/favicon.svg new file mode 100644 index 0000000..cde4956 --- /dev/null +++ b/client/public/favicon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/src/App.jsx b/client/src/App.jsx index 9ef2e1a..505f7d9 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -149,9 +149,9 @@ export default function App() { style={{ background: showSettings ? '#1e2a3a' : 'transparent', border: 'none', borderRadius: 6, - color: showSettings ? '#7ab8f5' : '#555', - cursor: 'pointer', fontSize: 16, - width: 32, height: 32, + color: showSettings ? '#7ab8f5' : '#888', + cursor: 'pointer', fontSize: 20, + width: 38, height: 38, display: 'flex', alignItems: 'center', justifyContent: 'center', }} > diff --git a/client/src/api/photos.js b/client/src/api/photos.js index 9f6d0bb..55eb710 100644 --- a/client/src/api/photos.js +++ b/client/src/api/photos.js @@ -104,6 +104,11 @@ export function clearDismissals() { return api('/api/admin/clear-dismissals', { method: 'POST' }); } +// Photo sync +export function triggerSync(source) { + return api(`/api/sync/${source}`, { method: 'POST' }); +} + // Duplicates export function fetchDuplicates() { return api('/api/duplicates'); diff --git a/client/src/components/SettingsPanel.jsx b/client/src/components/SettingsPanel.jsx index d0c107d..7eb6d88 100644 --- a/client/src/components/SettingsPanel.jsx +++ b/client/src/components/SettingsPanel.jsx @@ -2,15 +2,24 @@ import { useState, useEffect } from 'react'; import { fetchSettings, patchSettings, fetchWatchFolders, addWatchFolder, removeWatchFolder, toggleWatchFolder, - clearDismissals, fetchDirBrowser, + clearDismissals, fetchDirBrowser, triggerSync, } from '../api/photos.js'; +const EMPTY_SYNC = { + icloud: { running: false, lines: [], exitCode: null, startedAt: null, error: null }, + amazon: { running: false, lines: [], exitCode: null, startedAt: null, error: null }, +}; + export default function SettingsPanel({ onClose }) { const [settings, setSettings] = useState(null); const [folders, setFolders] = useState([]); const [newPath, setNewPath] = useState(''); const [saving, setSaving] = useState(false); const [cleared, setCleared] = useState(false); + const [syncStatus, setSyncStatus] = useState(EMPTY_SYNC); + const [syncError, setSyncError] = useState({}); + const [amazonPath, setAmazonPath] = useState(''); + const [amazonAdded, setAmazonAdded] = useState(false); // Folder browser const [browseOpen, setBrowseOpen] = useState(false); @@ -22,6 +31,12 @@ export default function SettingsPanel({ onClose }) { fetchWatchFolders().then(setFolders); }, []); + useEffect(() => { + const es = new EventSource('/api/sync/stream'); + es.onmessage = (e) => setSyncStatus(JSON.parse(e.data)); + return () => es.close(); + }, []); + // Debounced setting save async function handleSetting(key, value) { const updated = { ...settings, [key]: value }; @@ -85,6 +100,25 @@ export default function SettingsPanel({ onClose }) { setTimeout(() => setCleared(false), 2000); } + async function handleSync(source) { + setSyncError(prev => ({ ...prev, [source]: null })); + try { + await triggerSync(source); + } catch (e) { + setSyncError(prev => ({ ...prev, [source]: e.message })); + } + } + + async function handleAmazonImport() { + const p = amazonPath.trim(); + if (!p) return; + const updated = await addWatchFolder(p); + setFolders(updated); + setAmazonPath(''); + setAmazonAdded(true); + setTimeout(() => setAmazonAdded(false), 3000); + } + return ( // Backdrop
+ {/* ── Photo Sync ── */} +
+ + {/* iCloud Photos */} + + handleSetting('icloud_enabled', v)} + /> + {settings.icloud_enabled && ( + <> + handleSetting('icloud_apple_id', v)} + /> + handleSetting('icloud_password', v)} + /> + handleSetting('icloud_dest', v)} + /> + handleSetting('icloud_cookie_dir', v)} + /> + handleSync('icloud')} + /> +

+ First run: docker exec <container> icloudpd --username <id> --auth-only --cookie-directory /data/icloud-cookies +

+ + )} + + {/* Amazon Photos */} + +

+ Request your library at amazon.com → Account → Request Your Data → Amazon Photos. + Extract the ZIP, copy to your server, then add the folder path here. +

+
+ setAmazonPath(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleAmazonImport()} + placeholder="/photos/amazon-export" + style={{ + flex: 1, background: '#1a1a1a', border: '1px solid #2a2a2a', + borderRadius: 5, color: '#ccc', fontSize: 11, + padding: '5px 8px', outline: 'none', fontFamily: 'monospace', + }} + /> + +
+ +

+ Also add the iCloud download folder to Watch Folders once sync runs. +

+
+ {/* ── Danger Zone ── */}
@@ -389,3 +510,148 @@ function DangerBtn({ onClick, children }) { ); } + +function SyncSourceHeader({ label }) { + return ( +
+ {label} +
+ ); +} + +function ToggleSetting({ label, value, onChange }) { + return ( +
+ {label} + +
+ ); +} + +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 && ( + + )} + + {lastRunLabel && !job.running && ( + + Last: {lastRunLabel} + {job.exitCode !== null && ( + + {job.exitCode === 0 ? '✓' : `✗ (${job.exitCode})`} + + )} + + )} + + {job.lines.length > 0 && ( + + )} +
+ + {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)),