diff --git a/client/src/App.jsx b/client/src/App.jsx index eb9cba9..edaca26 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -5,6 +5,7 @@ import Lightbox from './components/Lightbox.jsx'; import FilterSidebar from './components/FilterSidebar.jsx'; import IngestBadge from './components/IngestBadge.jsx'; import DuplicatesView from './components/DuplicatesView.jsx'; +import SettingsPanel from './components/SettingsPanel.jsx'; import { lazy, Suspense } from 'react'; const MapView = lazy(() => import('./components/MapView.jsx')); @@ -24,8 +25,9 @@ export default function App() { const [filters, setFilters] = useState(EMPTY_FILTERS); const [stats, setStats] = useState(null); - const [lbPhotos, setLbPhotos] = useState(null); - const [lbIndex, setLbIndex] = useState(null); + const [lbPhotos, setLbPhotos] = useState(null); + const [lbIndex, setLbIndex] = useState(null); + const [showSettings, setShowSettings] = useState(false); const loadingRef = useRef(false); const sentinelRef = useRef(null); @@ -138,9 +140,23 @@ export default function App() { - {/* Ingest progress — right side */} -
+ {/* Right side: ingest badge + gear */} +
+
@@ -217,6 +233,9 @@ export default function App() {
+ {/* Settings panel */} + {showSettings && setShowSettings(false)} />} + {/* Lightbox */} {lbPhotos !== null && ( { + fetchSettings().then(setSettings); + fetchWatchFolders().then(setFolders); + }, []); + + // Debounced setting save + async function handleSetting(key, value) { + const updated = { ...settings, [key]: value }; + setSettings(updated); + setSaving(true); + try { + const saved = await patchSettings({ [key]: value }); + setSettings(saved); + } finally { + setSaving(false); + } + } + + async function handleAddFolder() { + const p = newPath.trim(); + if (!p) return; + const updated = await addWatchFolder(p); + setFolders(updated); + setNewPath(''); + } + + async function handleRemoveFolder(id) { + await removeWatchFolder(id); + setFolders(f => f.filter(x => x.id !== id)); + } + + async function handleToggleFolder(id, enabled) { + const updated = await toggleWatchFolder(id, !enabled); + setFolders(updated); + } + + async function handleClearDismissals() { + await clearDismissals(); + setCleared(true); + setTimeout(() => setCleared(false), 2000); + } + + return ( + // Backdrop +
+ {/* Panel */} +
e.stopPropagation()} + style={{ + position: 'absolute', top: 0, right: 0, + width: 380, height: '100%', + background: '#111', borderLeft: '1px solid #1e1e1e', + display: 'flex', flexDirection: 'column', + overflowY: 'auto', + }} + > + {/* Header */} +
+ Settings + {saving && Saving…} + +
+ + {settings === null ? ( +

Loading…

+ ) : ( +
+ + {/* ── Watch Folders ── */} +
+

+ Add absolute paths to scan. In Docker, the path must be mounted as a volume. +

+ + {folders.map(f => ( +
+ +
+ ))} + +
+ setNewPath(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleAddFolder()} + placeholder="/path/to/photos" + style={{ + flex: 1, background: '#1a1a1a', border: '1px solid #2a2a2a', + borderRadius: 5, color: '#ccc', fontSize: 12, + padding: '5px 8px', outline: 'none', fontFamily: 'monospace', + }} + /> + +
+
+ + {/* ── Ingestion ── */} +
+ handleSetting('thumb_size', v)} /> + handleSetting('thumb_quality', v)} /> + handleSetting('preview_size', v)} /> + handleSetting('preview_quality', v)} /> +

+ Changes apply to newly ingested photos. Existing thumbnails are not regenerated. +

+
+ + {/* ── Duplicate Detection ── */} +
+ handleSetting('dedupe_threshold', v)} /> +

+ Hamming distance — lower = stricter (fewer, more exact matches). +

+
+ + {/* ── Danger Zone ── */} +
+
+ + {cleared ? '✓ Done' : 'Clear dismissed duplicates'} + +

+ Resets all "Not duplicates" decisions so groups reappear in the review list. +

+
+
+ +
+ )} +
+
+ ); +} + +function Section({ title, children }) { + return ( +
+
+ {title} +
+ {children} +
+ ); +} + +function NumSetting({ label, unit, min, max, step = 1, value, onChange }) { + return ( +
+ {label} + { + const n = parseInt(e.target.value, 10); + if (!isNaN(n) && n >= min && n <= max) onChange(n); + }} + style={{ + width: 70, background: '#1a1a1a', border: '1px solid #2a2a2a', + borderRadius: 5, color: '#ccc', fontSize: 12, + padding: '4px 8px', outline: 'none', textAlign: 'right', + }} + /> + {unit && {unit}} +
+ ); +} + +function DangerBtn({ onClick, children }) { + return ( + + ); +} diff --git a/server/dedupe.js b/server/dedupe.js index 403d61c..41387b6 100644 --- a/server/dedupe.js +++ b/server/dedupe.js @@ -1,9 +1,7 @@ import { hammingDistance } from '../worker/hash.js'; -const THRESHOLD = parseInt(process.env.DEDUPE_THRESHOLD ?? '10', 10); - -// Run pairwise pHash comparison and store new similar pairs. -export function scan(db) { +export function scan(db, threshold) { + const THRESHOLD = threshold ?? parseInt(process.env.DEDUPE_THRESHOLD ?? '10', 10); const start = Date.now(); const photos = db.raw.prepare( @@ -30,13 +28,10 @@ export function scan(db) { return { photos: photos.length, newPairs, duration: Date.now() - start }; } -// Build connected-component groups from non-dismissed pairs. -// Returns an array of photo-row arrays, largest groups first. export function getGroups(db) { const pairs = db.getDupePairs(); if (!pairs.length) return []; - // Union-Find with path compression const parent = new Map(); function find(x) { @@ -45,18 +40,11 @@ export function getGroups(db) { return parent.get(x); } - function union(a, b) { - parent.set(find(a), find(b)); - } + function union(a, b) { parent.set(find(a), find(b)); } - for (const { photo_id_a, photo_id_b } of pairs) { - union(photo_id_a, photo_id_b); - } - - // Normalize roots (path compression may have left stale entries) + for (const { photo_id_a, photo_id_b } of pairs) union(photo_id_a, photo_id_b); for (const id of [...parent.keys()]) find(id); - // Group IDs by root const groups = new Map(); for (const id of parent.keys()) { const root = find(id); @@ -64,7 +52,6 @@ export function getGroups(db) { groups.get(root).push(id); } - // Fetch photo details for all IDs const allIds = [...parent.keys()]; const rows = db.raw.prepare( `SELECT id, thumb_path, preview_path, date_taken, filesize, width, height, camera_model diff --git a/server/index.js b/server/index.js index 903e318..657b5de 100644 --- a/server/index.js +++ b/server/index.js @@ -246,8 +246,10 @@ app.delete('/api/photos/:id/collections/:colId', (req, res) => { // ── Duplicates ──────────────────────────────────────────────────────────────── app.post('/api/dedupe/scan', (_req, res) => { - try { res.json(scan(db)); } - catch (e) { res.status(500).json({ error: e.message }); } + try { + const { dedupe_threshold } = db.getSettings(); + res.json(scan(db, dedupe_threshold)); + } catch (e) { res.status(500).json({ error: e.message }); } }); app.get('/api/duplicates', (_req, res) => { @@ -274,6 +276,48 @@ app.post('/api/duplicates/dismiss', (req, res) => { res.sendStatus(204); }); +// ── Settings ────────────────────────────────────────────────────────────────── + +app.get('/api/settings', (_req, res) => res.json(db.getSettings())); + +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); + } + res.json(db.getSettings()); +}); + +// ── Watch folders ───────────────────────────────────────────────────────────── + +app.get('/api/watch-folders', (_req, res) => res.json(db.listWatchFolders())); + +app.post('/api/watch-folders', (req, res) => { + const p = (req.body.path ?? '').trim(); + if (!p) return badRequest(res, 'path required'); + db.addWatchFolder(p); + res.status(201).json(db.listWatchFolders()); +}); + +app.delete('/api/watch-folders/:id', (req, res) => { + db.removeWatchFolder(parseInt(req.params.id, 10)); + res.sendStatus(204); +}); + +app.patch('/api/watch-folders/:id', (req, res) => { + db.toggleWatchFolder(parseInt(req.params.id, 10), req.body.enabled); + res.json(db.listWatchFolders()); +}); + +// ── Admin ───────────────────────────────────────────────────────────────────── + +app.post('/api/admin/clear-dismissals', (_req, res) => { + db.raw.prepare('UPDATE duplicate_pairs SET dismissed = 0').run(); + res.sendStatus(204); +}); + // ── SPA fallback ────────────────────────────────────────────────────────────── const distPath = path.resolve(__dirname, '../dist'); diff --git a/worker/db.js b/worker/db.js index 80e3062..275612e 100644 --- a/worker/db.js +++ b/worker/db.js @@ -2,6 +2,14 @@ import Database from 'better-sqlite3'; 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', +}; + export function openDb(dbPath) { fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true }); @@ -73,11 +81,27 @@ export function openDb(dbPath) { started_at TEXT, updated_at TEXT ); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS watch_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL UNIQUE, + enabled INTEGER NOT NULL DEFAULT 1, + added_at TEXT NOT NULL DEFAULT (datetime('now')) + ); `); - // Migrations for columns added after initial schema + // Column migrations try { db.exec('ALTER TABLE photos ADD COLUMN starred INTEGER NOT NULL DEFAULT 0'); } catch {} + // Seed default settings + const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); + for (const [k, v] of Object.entries(SETTING_DEFAULTS)) seedSetting.run(k, v); + const s = { insert: db.prepare(` INSERT OR IGNORE INTO photos @@ -98,29 +122,21 @@ export function openDb(dbPath) { FROM tags t LEFT JOIN photo_tags pt ON pt.tag_id = t.id GROUP BY t.id ORDER BY t.name `), - tagOfPhoto: db.prepare( - 'SELECT t.id, t.name FROM tags t JOIN photo_tags pt ON pt.tag_id = t.id WHERE pt.photo_id = ? ORDER BY t.name' - ), + tagOfPhoto: db.prepare('SELECT t.id, t.name FROM tags t JOIN photo_tags pt ON pt.tag_id = t.id WHERE pt.photo_id = ? ORDER BY t.name'), tagAttach: db.prepare('INSERT OR IGNORE INTO photo_tags (photo_id, tag_id) VALUES (?, ?)'), tagDetach: db.prepare('DELETE FROM photo_tags WHERE photo_id = ? AND tag_id = ?'), colList: db.prepare(` - SELECT c.id, c.name, c.description, c.created_at, - COUNT(pc.photo_id) AS photo_count - FROM collections c - LEFT JOIN photo_collections pc ON pc.collection_id = c.id + SELECT c.id, c.name, c.description, c.created_at, COUNT(pc.photo_id) AS photo_count + FROM collections c LEFT JOIN photo_collections pc ON pc.collection_id = c.id GROUP BY c.id ORDER BY c.name `), colInsert: db.prepare('INSERT INTO collections (name, description) VALUES (?, ?)'), - colOfPhoto: db.prepare( - 'SELECT c.id, c.name FROM collections c JOIN photo_collections pc ON pc.collection_id = c.id WHERE pc.photo_id = ? ORDER BY c.name' - ), + colOfPhoto: db.prepare('SELECT c.id, c.name FROM collections c JOIN photo_collections pc ON pc.collection_id = c.id WHERE pc.photo_id = ? ORDER BY c.name'), colAdd: db.prepare('INSERT OR IGNORE INTO photo_collections (photo_id, collection_id) VALUES (?, ?)'), colRemove: db.prepare('DELETE FROM photo_collections WHERE photo_id = ? AND collection_id = ?'), - dupePairs: db.prepare( - 'SELECT photo_id_a, photo_id_b, distance FROM duplicate_pairs WHERE dismissed = 0' - ), + dupePairs: db.prepare('SELECT photo_id_a, photo_id_b, distance FROM duplicate_pairs WHERE dismissed = 0'), ingestGet: db.prepare('SELECT * FROM ingest_status WHERE id = 1'), ingestSet: db.prepare(` @@ -134,6 +150,14 @@ export function openDb(dbPath) { started_at = COALESCE(ingest_status.started_at, excluded.started_at), updated_at = excluded.updated_at `), + + settingsAll: db.prepare('SELECT key, value FROM settings'), + settingsSet: db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'), + + foldersAll: db.prepare('SELECT id, path, enabled, added_at FROM watch_folders ORDER BY added_at'), + folderInsert: db.prepare('INSERT OR IGNORE INTO watch_folders (path) VALUES (?)'), + folderDelete: db.prepare('DELETE FROM watch_folders WHERE id = ?'), + folderToggle: db.prepare('UPDATE watch_folders SET enabled = ? WHERE id = ?'), }; const addTagTx = db.transaction((photoId, tagName) => { @@ -156,14 +180,14 @@ export function openDb(dbPath) { getPhotoTags: (photoId) => s.tagOfPhoto.all(photoId), listAllTags: () => s.tagList.all(), - listCollections: () => s.colList.all(), - createCollection: (name, desc) => { + listCollections: () => s.colList.all(), + createCollection: (name, desc) => { const info = s.colInsert.run(name, desc ?? null); return { id: info.lastInsertRowid, name, description: desc ?? null }; }, - addPhotoToCollection: (photoId, colId) => s.colAdd.run(photoId, colId), - removePhotoFromCollection: (photoId, colId) => s.colRemove.run(photoId, colId), - getPhotoCollections: (photoId) => s.colOfPhoto.all(photoId), + addPhotoToCollection: (pid, cid) => s.colAdd.run(pid, cid), + removePhotoFromCollection: (pid, cid) => s.colRemove.run(pid, cid), + getPhotoCollections: (pid) => s.colOfPhoto.all(pid), getDupePairs: () => s.dupePairs.all(), dismissDuplicates: (ids) => { @@ -176,15 +200,26 @@ export function openDb(dbPath) { getIngestStatus: () => s.ingestGet.get() ?? { active: 0, current: null, scanned: 0, errors: 0 }, - setIngestStatus: ({ active, current = null, scanned = 0, errors = 0 }) => s.ingestSet.run({ - active: active ? 1 : 0, - current, - scanned, - errors, + active: active ? 1 : 0, current, scanned, errors, started_at: active ? new Date().toISOString() : null, updated_at: new Date().toISOString(), }), + + getSettings: () => { + 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); + return out; + }, + setSetting: (key, value) => s.settingsSet.run(key, String(value)), + + listWatchFolders: () => s.foldersAll.all(), + addWatchFolder: (p) => s.folderInsert.run(p), + removeWatchFolder: (id) => s.folderDelete.run(id), + toggleWatchFolder: (id, en) => s.folderToggle.run(en ? 1 : 0, id), }; } diff --git a/worker/index.js b/worker/index.js index b4592da..91752a6 100644 --- a/worker/index.js +++ b/worker/index.js @@ -1,23 +1,28 @@ import 'dotenv/config'; import chokidar from 'chokidar'; import path from 'path'; +import fs from 'fs'; import { openDb } from './db.js'; import { ingestFile, isSupported } from './ingest.js'; -const PHOTO_DIR = process.env.PHOTO_DIR ?? './photos'; +const PHOTO_DIR = process.env.PHOTO_DIR ?? null; const DB_PATH = process.env.DB_PATH ?? './data/library.db'; const POSTER_DIR = process.env.POSTER_DIR ?? './data/posters'; const db = openDb(DB_PATH); -// Load all known paths into memory once so chokidar's initial 'add' burst -// for an already-indexed library costs one bulk query instead of N serial -// DB lookups through the drain queue. +// Seed PHOTO_DIR env var as the first watch folder (if set) +if (PHOTO_DIR) { + const resolved = path.resolve(PHOTO_DIR); + if (fs.existsSync(resolved)) db.addWatchFolder(resolved); +} + +// Load all known filepaths into memory so the initial chokidar burst skips +// already-indexed files without touching the drain queue. const indexed = new Set( db.raw.prepare('SELECT filepath FROM photos').all().map(r => r.filepath) ); -console.log(`[worker] watching ${path.resolve(PHOTO_DIR)}`); console.log(`[worker] db at ${path.resolve(DB_PATH)} (${indexed.size} photos indexed)`); const queue = []; @@ -28,13 +33,13 @@ let errors = 0; async function drain() { if (busy) return; busy = true; - db.setIngestStatus({ active: true, scanned, errors }); while (queue.length > 0) { - const fp = queue.shift(); + const fp = queue.shift(); + const settings = db.getSettings(); try { - await ingestFile(fp, db, POSTER_DIR); + await ingestFile(fp, db, POSTER_DIR, settings); indexed.add(fp); scanned++; db.setIngestStatus({ active: true, current: path.basename(fp), scanned, errors }); @@ -49,7 +54,8 @@ async function drain() { busy = false; } -const watcher = chokidar.watch(PHOTO_DIR, { +// Single chokidar instance — paths are added/removed dynamically +const watcher = chokidar.watch([], { persistent: true, ignoreInitial: false, awaitWriteFinish: { stabilityThreshold: 2000, pollInterval: 100 }, @@ -59,7 +65,7 @@ const watcher = chokidar.watch(PHOTO_DIR, { watcher.on('add', (fp) => { if (!isSupported(fp)) return; const resolved = path.resolve(fp); - if (indexed.has(resolved)) return; // already in DB — skip entirely + if (indexed.has(resolved)) return; queue.push(resolved); drain(); }); @@ -73,6 +79,34 @@ watcher.on('unlink', (fp) => { watcher.on('error', (err) => console.error('[worker] watcher error:', err)); +// Sync watched paths against watch_folders table +const watching = new Set(); + +function syncWatchFolders() { + const folders = db.listWatchFolders().filter(f => f.enabled); + + for (const { path: p } of folders) { + if (!watching.has(p)) { + console.log(`[worker] watching ${p}`); + watcher.add(p); + watching.add(p); + } + } + + const enabledPaths = new Set(folders.map(f => f.path)); + for (const p of watching) { + if (!enabledPaths.has(p)) { + console.log(`[worker] stopped watching ${p}`); + watcher.unwatch(p); + watching.delete(p); + } + } +} + +// Initial sync then poll every 30s for UI-added folders +syncWatchFolders(); +setInterval(syncWatchFolders, 30_000); + process.on('SIGINT', () => { console.log('\n[worker] shutting down'); db.setIngestStatus({ active: false, current: null, scanned, errors }); diff --git a/worker/ingest.js b/worker/ingest.js index 803b12d..8d6d2b7 100644 --- a/worker/ingest.js +++ b/worker/ingest.js @@ -14,9 +14,14 @@ export function isSupported(filepath) { return SUPPORTED.has(path.extname(filepath).toLowerCase()); } -export async function ingestFile(filepath, db, posterDir) { +export async function ingestFile(filepath, db, posterDir, settings = {}) { if (db.hasPhoto(filepath)) return; + const thumbSize = settings.thumb_size ?? 400; + const thumbQuality = settings.thumb_quality ?? 80; + const previewSize = settings.preview_size ?? 1600; + const previewQuality = settings.preview_quality ?? 85; + const stat = fs.statSync(filepath); let exif = {}; @@ -27,10 +32,10 @@ export async function ingestFile(filepath, db, posterDir) { }) ?? {}; } catch {} - const dateTaken = exif.DateTimeOriginal ?? null; - const cameraModel = [exif.Make, exif.Model].filter(Boolean).join(' ') || null; - const gpsLat = exif.latitude ?? null; - const gpsLon = exif.longitude ?? null; + const dateTaken = exif.DateTimeOriginal ?? null; + const cameraModel = [exif.Make, exif.Model].filter(Boolean).join(' ') || null; + const gpsLat = exif.latitude ?? null; + const gpsLon = exif.longitude ?? null; const { dir, thumb, preview } = posterPaths(filepath, posterDir, dateTaken ?? stat.mtime); fs.mkdirSync(dir, { recursive: true }); @@ -44,14 +49,14 @@ export async function ingestFile(filepath, db, posterDir) { await sharp(filepath, { failOn: 'none' }) .rotate() - .resize(400, 400, { fit: 'inside', withoutEnlargement: true }) - .jpeg({ quality: 80 }) + .resize(thumbSize, thumbSize, { fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: thumbQuality }) .toFile(thumb); await sharp(filepath, { failOn: 'none' }) .rotate() - .resize(1600, 1600, { fit: 'inside', withoutEnlargement: true }) - .jpeg({ quality: 85 }) + .resize(previewSize, previewSize, { fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: previewQuality }) .toFile(preview); } catch (err) { console.error(`[ingest] poster failed ${path.basename(filepath)}: ${err.message}`); @@ -63,17 +68,17 @@ export async function ingestFile(filepath, db, posterDir) { db.insertPhoto({ filepath, - thumb_path: thumb, - preview_path: preview, - date_taken: dateTaken ? new Date(dateTaken).toISOString() : null, + thumb_path: thumb, + preview_path: preview, + date_taken: dateTaken ? new Date(dateTaken).toISOString() : null, date_imported: new Date().toISOString(), - camera_model: cameraModel, - gps_lat: gpsLat, - gps_lon: gpsLon, + camera_model: cameraModel, + gps_lat: gpsLat, + gps_lon: gpsLon, phash, width, height, - filesize: stat.size, + filesize: stat.size, }); console.log(`[ingest] ${path.basename(filepath)}`);