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
+3
-1
@@ -13,7 +13,8 @@ RUN npm run build
|
|||||||
# ── Stage 2: runtime ─────────────────────────────────────────────────────────
|
# ── Stage 2: runtime ─────────────────────────────────────────────────────────
|
||||||
FROM node:22-alpine
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ RUN npm ci --omit=dev
|
|||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY worker ./worker
|
COPY worker ./worker
|
||||||
COPY server ./server
|
COPY server ./server
|
||||||
|
COPY scripts ./scripts
|
||||||
|
|
||||||
ENV PORT=3000 \
|
ENV PORT=3000 \
|
||||||
DB_PATH=/data/library.db \
|
DB_PATH=/data/library.db \
|
||||||
|
|||||||
+2
-1
@@ -3,7 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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={{
|
style={{
|
||||||
background: showSettings ? '#1e2a3a' : 'transparent',
|
background: showSettings ? '#1e2a3a' : 'transparent',
|
||||||
border: 'none', borderRadius: 6,
|
border: 'none', borderRadius: 6,
|
||||||
color: showSettings ? '#7ab8f5' : '#555',
|
color: showSettings ? '#7ab8f5' : '#888',
|
||||||
cursor: 'pointer', fontSize: 16,
|
cursor: 'pointer', fontSize: 20,
|
||||||
width: 32, height: 32,
|
width: 38, height: 38,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -104,6 +104,11 @@ export function clearDismissals() {
|
|||||||
return api('/api/admin/clear-dismissals', { method: 'POST' });
|
return api('/api/admin/clear-dismissals', { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Photo sync
|
||||||
|
export function triggerSync(source) {
|
||||||
|
return api(`/api/sync/${source}`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
// Duplicates
|
// Duplicates
|
||||||
export function fetchDuplicates() {
|
export function fetchDuplicates() {
|
||||||
return api('/api/duplicates');
|
return api('/api/duplicates');
|
||||||
|
|||||||
@@ -2,15 +2,24 @@ import { useState, useEffect } from 'react';
|
|||||||
import {
|
import {
|
||||||
fetchSettings, patchSettings,
|
fetchSettings, patchSettings,
|
||||||
fetchWatchFolders, addWatchFolder, removeWatchFolder, toggleWatchFolder,
|
fetchWatchFolders, addWatchFolder, removeWatchFolder, toggleWatchFolder,
|
||||||
clearDismissals, fetchDirBrowser,
|
clearDismissals, fetchDirBrowser, triggerSync,
|
||||||
} from '../api/photos.js';
|
} 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 }) {
|
export default function SettingsPanel({ onClose }) {
|
||||||
const [settings, setSettings] = useState(null);
|
const [settings, setSettings] = useState(null);
|
||||||
const [folders, setFolders] = useState([]);
|
const [folders, setFolders] = useState([]);
|
||||||
const [newPath, setNewPath] = useState('');
|
const [newPath, setNewPath] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [cleared, setCleared] = 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
|
// Folder browser
|
||||||
const [browseOpen, setBrowseOpen] = useState(false);
|
const [browseOpen, setBrowseOpen] = useState(false);
|
||||||
@@ -22,6 +31,12 @@ export default function SettingsPanel({ onClose }) {
|
|||||||
fetchWatchFolders().then(setFolders);
|
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
|
// Debounced setting save
|
||||||
async function handleSetting(key, value) {
|
async function handleSetting(key, value) {
|
||||||
const updated = { ...settings, [key]: value };
|
const updated = { ...settings, [key]: value };
|
||||||
@@ -85,6 +100,25 @@ export default function SettingsPanel({ onClose }) {
|
|||||||
setTimeout(() => setCleared(false), 2000);
|
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 (
|
return (
|
||||||
// Backdrop
|
// Backdrop
|
||||||
<div
|
<div
|
||||||
@@ -314,6 +348,93 @@ export default function SettingsPanel({ onClose }) {
|
|||||||
</p>
|
</p>
|
||||||
</Section>
|
</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 ── */}
|
{/* ── Danger Zone ── */}
|
||||||
<Section title="Danger Zone">
|
<Section title="Danger Zone">
|
||||||
<div style={{ padding: '4px 18px' }}>
|
<div style={{ padding: '4px 18px' }}>
|
||||||
@@ -389,3 +510,148 @@ function DangerBtn({ onClick, children }) {
|
|||||||
</button>
|
</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()
|
||||||
+112
-4
@@ -3,6 +3,7 @@ import express from 'express';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
import { openDb } from '../worker/db.js';
|
import { openDb } from '../worker/db.js';
|
||||||
import { scan, getGroups } from './dedupe.js';
|
import { scan, getGroups } from './dedupe.js';
|
||||||
|
|
||||||
@@ -17,6 +18,57 @@ const posterDirAbs = path.resolve(POSTER_DIR);
|
|||||||
|
|
||||||
app.use(express.json());
|
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 ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function posterUrl(storedPath) {
|
function posterUrl(storedPath) {
|
||||||
@@ -285,16 +337,72 @@ app.post('/api/duplicates/dismiss', (req, res) => {
|
|||||||
res.sendStatus(204);
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/settings', (_req, res) => res.json(db.getSettings()));
|
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) => {
|
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)) {
|
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);
|
const n = parseInt(value, 10);
|
||||||
if (!isNaN(n)) db.setSetting(key, n);
|
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());
|
res.json(db.getSettings());
|
||||||
});
|
});
|
||||||
|
|||||||
+20
-7
@@ -3,13 +3,26 @@ import path from 'path';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
const SETTING_DEFAULTS = {
|
const SETTING_DEFAULTS = {
|
||||||
thumb_size: '400',
|
thumb_size: '400',
|
||||||
thumb_quality: '80',
|
thumb_quality: '80',
|
||||||
preview_size: '1600',
|
preview_size: '1600',
|
||||||
preview_quality: '85',
|
preview_quality: '85',
|
||||||
dedupe_threshold: '10',
|
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) {
|
export function openDb(dbPath) {
|
||||||
fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
|
fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
|
||||||
|
|
||||||
@@ -218,8 +231,8 @@ export function openDb(dbPath) {
|
|||||||
const rows = s.settingsAll.all();
|
const rows = s.settingsAll.all();
|
||||||
const out = { ...SETTING_DEFAULTS };
|
const out = { ...SETTING_DEFAULTS };
|
||||||
for (const { key, value } of rows) out[key] = value;
|
for (const { key, value } of rows) out[key] = value;
|
||||||
// Parse numerics
|
for (const k of NUMERIC_SETTINGS) out[k] = parseInt(out[k], 10);
|
||||||
for (const k of Object.keys(SETTING_DEFAULTS)) out[k] = parseInt(out[k], 10);
|
for (const k of BOOL_SETTINGS) out[k] = out[k] === '1' || out[k] === true;
|
||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
setSetting: (key, value) => s.settingsSet.run(key, String(value)),
|
setSetting: (key, value) => s.settingsSet.run(key, String(value)),
|
||||||
|
|||||||
Reference in new issue
Block a user