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
+562
-10
No files matched your search
+3
-1
@@ -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 \
|
||||
|
||||
+2
-1
@@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>shpm</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Luma</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background -->
|
||||
<rect width="100" height="100" rx="22" fill="#0d0d0d"/>
|
||||
|
||||
<!-- Outer lens ring -->
|
||||
<circle cx="50" cy="50" r="40" fill="none" stroke="#4d9eff" stroke-width="1.5" opacity="0.22"/>
|
||||
|
||||
<!-- 6 aperture blades -->
|
||||
<g transform="translate(50,50)" fill="#4d9eff">
|
||||
<polygon points="-11,-33 11,-33 5,-13 -5,-13" transform="rotate(0)"/>
|
||||
<polygon points="-11,-33 11,-33 5,-13 -5,-13" transform="rotate(60)"/>
|
||||
<polygon points="-11,-33 11,-33 5,-13 -5,-13" transform="rotate(120)"/>
|
||||
<polygon points="-11,-33 11,-33 5,-13 -5,-13" transform="rotate(180)"/>
|
||||
<polygon points="-11,-33 11,-33 5,-13 -5,-13" transform="rotate(240)"/>
|
||||
<polygon points="-11,-33 11,-33 5,-13 -5,-13" transform="rotate(300)"/>
|
||||
</g>
|
||||
|
||||
<!-- Center opening -->
|
||||
<circle cx="50" cy="50" r="10" fill="#0d0d0d"/>
|
||||
|
||||
<!-- Lens highlight -->
|
||||
<ellipse cx="43" cy="37" rx="5" ry="3.5" fill="white" opacity="0.13" transform="rotate(-30 43 37)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1023 B |
+3
-3
@@ -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',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
<div
|
||||
@@ -314,6 +348,93 @@ export default function SettingsPanel({ onClose }) {
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
{/* ── Photo Sync ── */}
|
||||
<Section title="Photo Sync">
|
||||
|
||||
{/* iCloud Photos */}
|
||||
<SyncSourceHeader label="iCloud Photos" />
|
||||
<ToggleSetting
|
||||
label="Enabled"
|
||||
value={!!settings.icloud_enabled}
|
||||
onChange={v => handleSetting('icloud_enabled', v)}
|
||||
/>
|
||||
{settings.icloud_enabled && (
|
||||
<>
|
||||
<TextSetting
|
||||
label="Apple ID"
|
||||
value={settings.icloud_apple_id}
|
||||
placeholder="you@icloud.com"
|
||||
onChange={v => handleSetting('icloud_apple_id', v)}
|
||||
/>
|
||||
<TextSetting
|
||||
label="Password"
|
||||
type="password"
|
||||
value={settings.icloud_password}
|
||||
placeholder="optional after first auth"
|
||||
onChange={v => handleSetting('icloud_password', v)}
|
||||
/>
|
||||
<TextSetting
|
||||
label="Download to"
|
||||
value={settings.icloud_dest}
|
||||
placeholder="/photos/icloud"
|
||||
onChange={v => handleSetting('icloud_dest', v)}
|
||||
/>
|
||||
<TextSetting
|
||||
label="Cookie dir"
|
||||
value={settings.icloud_cookie_dir}
|
||||
placeholder="/data/icloud-cookies"
|
||||
onChange={v => handleSetting('icloud_cookie_dir', v)}
|
||||
/>
|
||||
<SyncControls
|
||||
job={syncStatus.icloud}
|
||||
error={syncError.icloud}
|
||||
onSync={() => handleSync('icloud')}
|
||||
/>
|
||||
<p style={{ fontSize: 11, color: '#3a3a3a', padding: '2px 18px 4px', lineHeight: 1.5 }}>
|
||||
First run: <code style={{ color: '#555' }}>docker exec <container> icloudpd --username <id> --auth-only --cookie-directory /data/icloud-cookies</code>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Amazon Photos */}
|
||||
<SyncSourceHeader label="Amazon Photos — one-time import" />
|
||||
<p style={{ fontSize: 11, color: '#3a3a3a', padding: '0 18px 8px', lineHeight: 1.6 }}>
|
||||
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.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 6, padding: '0 18px 6px' }}>
|
||||
<input
|
||||
value={amazonPath}
|
||||
onChange={e => 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',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAmazonImport}
|
||||
disabled={!amazonPath.trim()}
|
||||
style={{
|
||||
background: amazonAdded ? '#1a2f1a' : amazonPath.trim() ? '#1e2f45' : '#181818',
|
||||
border: '1px solid ' + (amazonAdded ? '#2a4a2a' : amazonPath.trim() ? '#2a4a6a' : '#222'),
|
||||
borderRadius: 5,
|
||||
color: amazonAdded ? '#4a9a4a' : amazonPath.trim() ? '#7ab8f5' : '#333',
|
||||
cursor: amazonPath.trim() ? 'pointer' : 'default',
|
||||
padding: '5px 12px', fontSize: 12, whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{amazonAdded ? '✓ Added' : 'Add & Index'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 11, color: '#3a3a3a', padding: '4px 18px 0', lineHeight: 1.5 }}>
|
||||
Also add the iCloud download folder to Watch Folders once sync runs.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
{/* ── Danger Zone ── */}
|
||||
<Section title="Danger Zone">
|
||||
<div style={{ padding: '4px 18px' }}>
|
||||
@@ -389,3 +510,148 @@ function DangerBtn({ onClick, children }) {
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncSourceHeader({ label }) {
|
||||
return (
|
||||
<div style={{
|
||||
fontSize: 11, color: '#555', fontWeight: 600,
|
||||
padding: '10px 18px 4px', borderTop: '1px solid #1a1a1a', marginTop: 6,
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleSetting({ label, value, onChange }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 18px' }}>
|
||||
<span style={{ flex: 1, fontSize: 13, color: '#aaa' }}>{label}</span>
|
||||
<button
|
||||
onClick={() => 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',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute', top: 2,
|
||||
left: value ? 18 : 2,
|
||||
width: 14, height: 14, borderRadius: '50%',
|
||||
background: value ? '#7ab8f5' : '#444',
|
||||
transition: 'left 0.15s',
|
||||
display: 'block',
|
||||
}} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 18px' }}>
|
||||
<span style={{ flex: 1, fontSize: 13, color: '#aaa', flexShrink: 0 }}>{label}</span>
|
||||
<input
|
||||
type={type}
|
||||
value={local}
|
||||
placeholder={placeholder}
|
||||
onChange={e => 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',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ padding: '6px 18px 2px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
onClick={onSync}
|
||||
disabled={job.running}
|
||||
style={{
|
||||
background: job.running ? '#141414' : '#1e2f45',
|
||||
border: '1px solid ' + (job.running ? '#222' : '#2a4a6a'),
|
||||
borderRadius: 5, color: job.running ? '#444' : '#7ab8f5',
|
||||
cursor: job.running ? 'default' : 'pointer',
|
||||
padding: '5px 14px', fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{job.running ? 'Syncing…' : 'Sync Now'}
|
||||
</button>
|
||||
|
||||
{job.running && (
|
||||
<span style={{
|
||||
width: 7, height: 7, borderRadius: '50%',
|
||||
background: '#4d9eff', display: 'inline-block',
|
||||
animation: 'pulse-dot 1.4s ease-in-out infinite',
|
||||
}} />
|
||||
)}
|
||||
|
||||
{lastRunLabel && !job.running && (
|
||||
<span style={{ fontSize: 11, color: '#3a3a3a' }}>
|
||||
Last: {lastRunLabel}
|
||||
{job.exitCode !== null && (
|
||||
<span style={{ color: job.exitCode === 0 ? '#4a7a4a' : '#7a3a3a', marginLeft: 6 }}>
|
||||
{job.exitCode === 0 ? '✓' : `✗ (${job.exitCode})`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{job.lines.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowLog(s => !s)}
|
||||
style={{
|
||||
background: 'none', border: 'none', color: '#3a3a3a',
|
||||
cursor: 'pointer', fontSize: 11, marginLeft: 'auto', padding: 0,
|
||||
}}
|
||||
>
|
||||
{showLog ? 'hide log' : 'show log'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{ fontSize: 11, color: '#884444', margin: '4px 0 0' }}>{error}</p>
|
||||
)}
|
||||
|
||||
{showLog && job.lines.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: 6, background: '#080808', border: '1px solid #1a1a1a',
|
||||
borderRadius: 4, padding: '6px 8px',
|
||||
maxHeight: 140, overflowY: 'auto',
|
||||
fontFamily: 'monospace', fontSize: 10, color: '#555',
|
||||
lineHeight: 1.6,
|
||||
}}>
|
||||
{job.lines.slice(-60).map((line, i) => (
|
||||
<div key={i} style={{ color: line.startsWith('Error') || line.startsWith(' Error') ? '#884444' : '#555' }}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Download new photos from Amazon Photos using browser-exported cookies.
|
||||
|
||||
Usage:
|
||||
amazon_sync.py <cookies.txt> <dest_dir>
|
||||
|
||||
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 <cookies.txt> <dest_dir>", 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()
|
||||
+110
-2
@@ -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;
|
||||
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());
|
||||
});
|
||||
|
||||
+15
-2
@@ -8,8 +8,21 @@ const SETTING_DEFAULTS = {
|
||||
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)),
|
||||
|
||||
Reference in new issue
Block a user