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:
nmemmertandClaude Sonnet 4.6 committed 2026-08-18 09:30:43 -04:00
1 parent 5ff7f38b85
commit 4ff5a6c8ca
9 files changed
+562 -10

No files matched your search

+3 -1
View File
@@ -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
View File
@@ -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>
+23
View File
@@ -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
View File
@@ -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',
}}
>
+5
View File
@@ -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');
+267 -1
View File
@@ -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 &lt;container&gt; icloudpd --username &lt;id&gt; --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>
);
}
+134
View File
@@ -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
View File
@@ -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
View File
@@ -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)),