diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..30d171c --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "luma-server", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "server"], + "port": 3000 + }, + { + "name": "luma-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev:client"], + "port": 5173 + } + ] +} diff --git a/client/src/App.jsx b/client/src/App.jsx index a8e9016..eb9cba9 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,15 +1,16 @@ -import { useState, useEffect, useRef } from 'react'; -import { fetchPhotos, fetchTags, fetchCollections } from './api/photos.js'; +import { useState, useEffect, useRef, useCallback } from 'react'; +import { fetchPhotos, fetchLibraryStats } from './api/photos.js'; import Timeline from './components/Timeline.jsx'; import Lightbox from './components/Lightbox.jsx'; -import FilterBar from './components/FilterBar.jsx'; +import FilterSidebar from './components/FilterSidebar.jsx'; +import IngestBadge from './components/IngestBadge.jsx'; import DuplicatesView from './components/DuplicatesView.jsx'; -// Lazy-load MapView so Leaflet CSS doesn't bloat the initial bundle import { lazy, Suspense } from 'react'; const MapView = lazy(() => import('./components/MapView.jsx')); -const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '' }; +const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '' }; +const SIDEBAR_W = 210; export default function App() { const [view, setView] = useState('library'); @@ -20,24 +21,21 @@ export default function App() { const [error, setError] = useState(null); const [dupeCount, setDupeCount] = useState(0); - // Filter state - const [filters, setFilters] = useState(EMPTY_FILTERS); - const [tags, setTags] = useState([]); - const [collections, setCollections] = useState([]); + const [filters, setFilters] = useState(EMPTY_FILTERS); + const [stats, setStats] = useState(null); - // Unified lightbox const [lbPhotos, setLbPhotos] = useState(null); const [lbIndex, setLbIndex] = useState(null); const loadingRef = useRef(false); const sentinelRef = useRef(null); - // Fetch dropdown data once - useEffect(() => { - fetchTags().then(setTags).catch(() => {}); - fetchCollections().then(setCollections).catch(() => {}); + const loadStats = useCallback(() => { + fetchLibraryStats().then(setStats).catch(() => {}); }, []); + useEffect(() => { loadStats(); }, []); + // Reset + reload when filters change (debounce text search) useEffect(() => { const delay = filters.q ? 300 : 0; @@ -84,8 +82,28 @@ export default function App() { setLbIndex(idx); } + // When a photo's star state changes in the lightbox, patch it in the local list + function handleStarChange(updated) { + setPhotos(prev => prev.map(p => p.id === updated.id ? { ...p, starred: updated.starred } : p)); + if (lbPhotos) { + setLbPhotos(prev => prev.map(p => p.id === updated.id ? { ...p, starred: updated.starred } : p)); + } + loadStats(); // refresh sidebar count + } + + // When ingest finishes, refresh photos + stats + function handleScanComplete() { + setPhotos([]); + setNextCursor(undefined); + loadPage(undefined, filters); + loadStats(); + } + + const hasFilter = filters.q || filters.from || filters.to || filters.tagId || filters.collectionId || filters.starred; + return ( <> + {/* ── Header ── */}
-
- {/* ── Library ── */} - {view === 'library' && ( - <> - - {error && ( -

- {error} -

- )} - {photos.length === 0 && !loading && !error && ( -
- {filters.q || filters.from || filters.to || filters.tagId || filters.collectionId ? ( -

No photos match these filters.

- ) : ( - <> -

No photos indexed yet.

-

Set PHOTO_DIR in .env and run npm run worker.

- + {/* ── Body (sidebar + content) ── */} +
+ + {/* Sidebar — only in library view */} + {view === 'library' && ( + + )} + + {/* Main content */} +
+ + {/* Library */} + {view === 'library' && ( + <> + {error && ( +

+ {error} +

)} -
+ {photos.length === 0 && !loading && !error && ( +
+ {hasFilter ? ( +

No photos match these filters.

+ ) : ( + <> +

No photos indexed yet.

+

Set PHOTO_DIR in .env and run npm run worker.

+ + )} +
+ )} + openLightbox(photos, idx)} /> +
+ {loading &&

Loading…

} + {nextCursor === null && photos.length > 0 && !loading && ( +

+ — {total?.toLocaleString()} photo{total === 1 ? '' : 's'} — +

+ )} + )} - openLightbox(photos, idx)} /> -
- {loading &&

Loading…

} - {nextCursor === null && photos.length > 0 && !loading && ( -

- — {total?.toLocaleString()} photo{total === 1 ? '' : 's'} — -

+ + {/* Map */} + {view === 'map' && ( + Loading map…

}> + openLightbox([photo], 0)} /> +
)} - - )} - {/* ── Map ── */} - {view === 'map' && ( - Loading map…

}> - openLightbox([photo], 0)} /> -
- )} + {/* Duplicates */} + {view === 'duplicates' && ( + + openLightbox(group, group.findIndex(p => p.id === photo.id)) + } + /> + )} +
+
- {/* ── Duplicates ── */} - {view === 'duplicates' && ( - - openLightbox(group, group.findIndex(p => p.id === photo.id)) - } - /> - )} - - {/* ── Lightbox (shared) ── */} + {/* Lightbox */} {lbPhotos !== null && ( { setLbPhotos(null); setLbIndex(null); }} onChange={setLbIndex} + onStarChange={handleStarChange} /> )} diff --git a/client/src/api/photos.js b/client/src/api/photos.js index c3a3a28..fdc533b 100644 --- a/client/src/api/photos.js +++ b/client/src/api/photos.js @@ -10,7 +10,7 @@ const json = (body) => ({ }); // Library -export function fetchPhotos({ cursor, limit = 100, q, from, to, tagId, collectionId } = {}) { +export function fetchPhotos({ cursor, limit = 100, q, from, to, tagId, collectionId, starred } = {}) { const p = new URLSearchParams({ limit }); if (cursor) p.set('cursor', cursor); if (q) p.set('q', q); @@ -18,6 +18,7 @@ export function fetchPhotos({ cursor, limit = 100, q, from, to, tagId, collectio if (to) p.set('to', to); if (tagId) p.set('tagId', tagId); if (collectionId) p.set('collectionId', collectionId); + if (starred) p.set('starred', starred); return api(`/api/photos?${p}`); } @@ -29,7 +30,19 @@ export function fetchMapPhotos() { return api('/api/photos/map'); } +export function fetchLibraryStats() { + return api('/api/library/stats'); +} + +export function toggleStar(id, starred) { + return api(`/api/photos/${id}`, { method: 'PATCH', ...json({ starred }) }); +} + // Tags +export function fetchTags() { + return api('/api/tags'); +} + export function addTag(photoId, name) { return api(`/api/photos/${photoId}/tags`, { method: 'POST', ...json({ name }) }); } @@ -55,11 +68,6 @@ export function removeFromCollection(photoId, collectionId) { return api(`/api/photos/${photoId}/collections/${collectionId}`, { method: 'DELETE' }); } -// Tags + collections (for filter dropdowns) -export function fetchTags() { - return api('/api/tags'); -} - // Duplicates export function fetchDuplicates() { return api('/api/duplicates'); diff --git a/client/src/components/FilterSidebar.jsx b/client/src/components/FilterSidebar.jsx new file mode 100644 index 0000000..84ec792 --- /dev/null +++ b/client/src/components/FilterSidebar.jsx @@ -0,0 +1,194 @@ +import { useState } from 'react'; + +const MAX_DEFAULT = 5; + +export default function FilterSidebar({ filters, onChange, stats, total }) { + const [expanded, setExpanded] = useState({}); + + function toggle(section) { + setExpanded(e => ({ ...e, [section]: !e[section] })); + } + + // Derive active sidebar selection + const selectedYear = (filters.from && filters.to) + ? filters.from.slice(0, 4) + : null; + const isStarred = filters.starred === '1'; + const isAll = !isStarred && !selectedYear && !filters.tagId && !filters.collectionId; + + function selectAll() { + onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '' }); + } + function selectStarred() { + onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '1' }); + } + function selectYear(year) { + onChange({ q: filters.q, from: `${year}-01-01`, to: `${year}-12-31`, tagId: '', collectionId: '', starred: '' }); + } + function selectTag(id) { + onChange({ q: filters.q, from: '', to: '', tagId: String(id), collectionId: '', starred: '' }); + } + function selectCollection(id) { + onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: String(id), starred: '' }); + } + + return ( + + ); +} + +function Section({ title, children }) { + return ( +
+
+ {title} +
+ {children} +
+ ); +} + +function Row({ active, onClick, icon, iconColor, label, count, indent }) { + return ( + + ); +} + +function SeeMore({ expanded, onClick }) { + return ( + + ); +} diff --git a/client/src/components/IngestBadge.jsx b/client/src/components/IngestBadge.jsx new file mode 100644 index 0000000..97cd2ae --- /dev/null +++ b/client/src/components/IngestBadge.jsx @@ -0,0 +1,53 @@ +import { useState, useEffect, useRef } from 'react'; + +export default function IngestBadge({ onScanComplete }) { + const [status, setStatus] = useState(null); + const wasActive = useRef(false); + + useEffect(() => { + const es = new EventSource('/api/ingest/stream'); + + es.onmessage = (e) => { + const data = JSON.parse(e.data); + setStatus(data); + if (wasActive.current && !data.active) { + onScanComplete?.(); + } + wasActive.current = !!data.active; + }; + + es.onerror = () => { + // Reconnect is handled automatically by EventSource + }; + + return () => es.close(); + }, []); + + if (!status?.active) return null; + + return ( +
+ + + Scanning · {status.scanned.toLocaleString()} indexed + + {status.current && ( + + {status.current} + + )} +
+ ); +} diff --git a/client/src/components/Lightbox.jsx b/client/src/components/Lightbox.jsx index a0e62f4..7e3f4b0 100644 --- a/client/src/components/Lightbox.jsx +++ b/client/src/components/Lightbox.jsx @@ -1,8 +1,10 @@ import { useState, useEffect } from 'react'; +import { toggleStar } from '../api/photos.js'; import DetailPanel from './DetailPanel.jsx'; -export default function Lightbox({ photos, index, onClose, onChange }) { - const [showInfo, setShowInfo] = useState(false); +export default function Lightbox({ photos, index, onClose, onChange, onStarChange }) { + const [showInfo, setShowInfo] = useState(false); + const [starring, setStarring] = useState(false); const photo = photos[index]; useEffect(() => { @@ -38,6 +40,23 @@ export default function Lightbox({ photos, index, onClose, onChange }) { onClick={e => e.stopPropagation()} style={{ position: 'absolute', top: 0, right: 0, display: 'flex', gap: 4, padding: 10, zIndex: 10 }} > + { + if (starring) return; + setStarring(true); + try { + const updated = await toggleStar(photo.id, !photo.starred); + onStarChange?.(updated); + } finally { + setStarring(false); + } + }} + active={photo.starred} + title={photo.starred ? 'Unstar' : 'Star'} + style={{ color: photo.starred ? '#f5c518' : undefined }} + > + ★ + setShowInfo(s => !s)} active={showInfo} diff --git a/client/src/index.css b/client/src/index.css index 8a7ea6c..f066ccd 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -16,6 +16,11 @@ body { min-height: 100dvh; } +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + code { font-family: 'SF Mono', 'Fira Code', monospace; background: #1e1e1e; diff --git a/server/index.js b/server/index.js index fc36c45..903e318 100644 --- a/server/index.js +++ b/server/index.js @@ -36,6 +36,7 @@ function mapPhoto(p) { width: p.width, height: p.height, filesize: p.filesize, + starred: p.starred === 1, thumb_url: posterUrl(p.thumb_path), preview_url: posterUrl(p.preview_path), }; @@ -44,9 +45,7 @@ function mapPhoto(p) { function badRequest(res, msg) { res.status(400).json({ error: msg }); } function notFound(res) { res.status(404).json({ error: 'not found' }); } -// Build SQL parts for the photo list query based on active filters. -// Returns { joinSql, whereSql, params } where params covers joins then where clauses. -function buildFilter(q, from, to, tagId, collectionId) { +function buildFilter(q, from, to, tagId, collectionId, starred) { const joinParts = []; const joinParams = []; const condParts = []; @@ -64,13 +63,14 @@ function buildFilter(q, from, to, tagId, collectionId) { condParts.push('(p.filepath LIKE ? OR p.camera_model LIKE ?)'); condParams.push(`%${q}%`, `%${q}%`); } - if (from) { condParts.push('p.date_taken >= ?'); condParams.push(from); } - if (to) { condParts.push('p.date_taken <= ?'); condParams.push(to); } + if (from) { condParts.push('p.date_taken >= ?'); condParams.push(from); } + if (to) { condParts.push('p.date_taken <= ?'); condParams.push(to); } + if (starred === '1') condParts.push('p.starred = 1'); return { - joinSql: joinParts.join(' '), + joinSql: joinParts.join(' '), whereSql: condParts.length ? `WHERE ${condParts.join(' AND ')}` : '', - params: [...joinParams, ...condParams], + params: [...joinParams, ...condParams], }; } @@ -78,37 +78,67 @@ function buildFilter(q, from, to, tagId, collectionId) { app.use('/posters', express.static(posterDirAbs)); +// ── Ingest status (SSE) ─────────────────────────────────────────────────────── + +app.get('/api/ingest/stream', (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', // prevent nginx buffering + }); + res.write('retry: 3000\n\n'); + + const send = () => { + const s = db.getIngestStatus(); + res.write(`data: ${JSON.stringify(s)}\n\n`); + }; + + send(); + const iv = setInterval(send, 1500); + req.on('close', () => clearInterval(iv)); +}); + +// ── Library stats ───────────────────────────────────────────────────────────── + +app.get('/api/library/stats', (_req, res) => { + const starred = db.raw.prepare('SELECT COUNT(*) AS n FROM photos WHERE starred = 1').get().n; + const years = db.raw.prepare( + "SELECT strftime('%Y', date_taken) AS year, COUNT(*) AS count FROM photos WHERE date_taken IS NOT NULL GROUP BY year ORDER BY year DESC" + ).all(); + res.json({ + total: db.count(), + starred, + years, + tags: db.listAllTags(), + collections: db.listCollections(), + }); +}); + // ── Photos ──────────────────────────────────────────────────────────────────── -app.get('/api/health', (_req, res) => { - res.json({ ok: true, photos: db.count() }); -}); +app.get('/api/health', (_req, res) => res.json({ ok: true, photos: db.count() })); const SELECT_COLS = ` p.id, p.thumb_path, p.preview_path, p.date_taken, p.date_imported, - p.camera_model, p.gps_lat, p.gps_lon, p.width, p.height, p.filesize + p.camera_model, p.gps_lat, p.gps_lon, p.width, p.height, p.filesize, p.starred `; app.get('/api/photos', (req, res) => { const limit = Math.min(parseInt(req.query.limit ?? '100', 10), 500); const cursor = req.query.cursor ?? null; - const { q, from, to, tagId, collectionId } = req.query; + const { q, from, to, tagId, collectionId, starred } = req.query; - const { joinSql, whereSql, params } = buildFilter(q, from, to, tagId, collectionId); + const { joinSql, whereSql, params } = buildFilter(q, from, to, tagId, collectionId, starred); - // Cursor condition — handles null dates correctly (NULLs sort last in DESC). - // When cursor_date is a real date: include photos before it AND all null-dated ones. - // When cursor_date is empty (we're in the null section): only null-dated with lower id. - let cursorSql = ''; + let cursorSql = ''; const cursorParams = []; if (cursor) { - const pipeAt = cursor.lastIndexOf('|'); + const pipeAt = cursor.lastIndexOf('|'); const cursorDate = cursor.slice(0, pipeAt); const cursorId = parseInt(cursor.slice(pipeAt + 1), 10); if (cursorDate === '') { - cursorSql = whereSql - ? 'AND (p.date_taken IS NULL AND p.id < ?)' - : 'WHERE (p.date_taken IS NULL AND p.id < ?)'; + cursorSql = whereSql ? 'AND (p.date_taken IS NULL AND p.id < ?)' : 'WHERE (p.date_taken IS NULL AND p.id < ?)'; cursorParams.push(cursorId); } else { const clause = '(p.date_taken < ? OR (p.date_taken = ? AND p.id < ?) OR p.date_taken IS NULL)'; @@ -118,13 +148,12 @@ app.get('/api/photos', (req, res) => { } const allParams = [...params, ...cursorParams, limit + 1]; + const sql = `SELECT ${SELECT_COLS} FROM photos p ${joinSql} ${whereSql} ${cursorSql} + ORDER BY p.date_taken DESC, p.id DESC LIMIT ?`; + const rows = db.raw.prepare(sql).all(allParams); - const sql = `SELECT ${SELECT_COLS} FROM photos p ${joinSql} ${whereSql} ${cursorSql} - ORDER BY p.date_taken DESC, p.id DESC LIMIT ?`; - const rows = db.raw.prepare(sql).all(allParams); - - const countSql = `SELECT COUNT(*) AS n FROM photos p ${joinSql} ${whereSql}`; - const total = db.raw.prepare(countSql).get(params).n; + const countSql = `SELECT COUNT(*) AS n FROM photos p ${joinSql} ${whereSql}`; + const total = db.raw.prepare(countSql).get(params).n; const hasMore = rows.length > limit; const page = rows.slice(0, limit); @@ -161,6 +190,16 @@ app.get('/api/photos/:id', (req, res) => { }); }); +app.patch('/api/photos/:id', (req, res) => { + const id = parseInt(req.params.id, 10); + if ('starred' in req.body) { + db.raw.prepare('UPDATE photos SET starred = ? WHERE id = ?').run(req.body.starred ? 1 : 0, id); + } + const row = db.raw.prepare('SELECT * FROM photos WHERE id = ?').get(id); + if (!row) return notFound(res); + res.json(mapPhoto(row)); +}); + // ── Tags ────────────────────────────────────────────────────────────────────── app.get('/api/tags', (_req, res) => res.json(db.listAllTags())); diff --git a/worker/db.js b/worker/db.js index ce82b7a..80e3062 100644 --- a/worker/db.js +++ b/worker/db.js @@ -23,17 +23,18 @@ export function openDb(dbPath) { phash TEXT, width INTEGER, height INTEGER, - filesize INTEGER + filesize INTEGER, + starred INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_photos_date_taken ON photos(date_taken); CREATE INDEX IF NOT EXISTS idx_photos_phash ON photos(phash); + CREATE INDEX IF NOT EXISTS idx_photos_starred ON photos(starred); CREATE TABLE IF NOT EXISTS collections ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, description TEXT, - poster_path TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -62,10 +63,22 @@ export function openDb(dbPath) { PRIMARY KEY (photo_id_a, photo_id_b), CHECK (photo_id_a < photo_id_b) ); + + CREATE TABLE IF NOT EXISTS ingest_status ( + id INTEGER PRIMARY KEY CHECK (id = 1), + active INTEGER NOT NULL DEFAULT 0, + current TEXT, + scanned INTEGER NOT NULL DEFAULT 0, + errors INTEGER NOT NULL DEFAULT 0, + started_at TEXT, + updated_at TEXT + ); `); + // Migrations for columns added after initial schema + try { db.exec('ALTER TABLE photos ADD COLUMN starred INTEGER NOT NULL DEFAULT 0'); } catch {} + const s = { - // Photos insert: db.prepare(` INSERT OR IGNORE INTO photos (filepath, thumb_path, preview_path, date_taken, date_imported, @@ -78,7 +91,6 @@ export function openDb(dbPath) { remove: db.prepare('DELETE FROM photos WHERE filepath = ?'), count: db.prepare('SELECT COUNT(*) AS n FROM photos'), - // Tags tagByName: db.prepare('SELECT id, name FROM tags WHERE name = ?'), tagInsert: db.prepare('INSERT OR IGNORE INTO tags (name) VALUES (?)'), tagList: db.prepare(` @@ -92,7 +104,6 @@ export function openDb(dbPath) { 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 = ?'), - // Collections colList: db.prepare(` SELECT c.id, c.name, c.description, c.created_at, COUNT(pc.photo_id) AS photo_count @@ -107,13 +118,22 @@ export function openDb(dbPath) { 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 = ?'), - // Duplicates - dupePairs: db.prepare( + dupePairs: db.prepare( 'SELECT photo_id_a, photo_id_b, distance FROM duplicate_pairs WHERE dismissed = 0' ), - dupePairCount: db.prepare( - 'SELECT COUNT(*) AS n FROM duplicate_pairs WHERE dismissed = 0' - ), + + ingestGet: db.prepare('SELECT * FROM ingest_status WHERE id = 1'), + ingestSet: db.prepare(` + INSERT INTO ingest_status (id, active, current, scanned, errors, started_at, updated_at) + VALUES (1, @active, @current, @scanned, @errors, @started_at, @updated_at) + ON CONFLICT(id) DO UPDATE SET + active = excluded.active, + current = excluded.current, + scanned = excluded.scanned, + errors = excluded.errors, + started_at = COALESCE(ingest_status.started_at, excluded.started_at), + updated_at = excluded.updated_at + `), }; const addTagTx = db.transaction((photoId, tagName) => { @@ -126,29 +146,25 @@ export function openDb(dbPath) { return { raw: db, - // Photos insertPhoto: (row) => s.insert.run(row), hasPhoto: (fp) => !!s.exists.get(fp), removePhoto: (fp) => s.remove.run(fp), count: () => s.count.get().n, - // Tags addTagToPhoto: (photoId, tagName) => addTagTx(photoId, tagName), removeTagFromPhoto: (photoId, tagId) => s.tagDetach.run(photoId, tagId), getPhotoTags: (photoId) => s.tagOfPhoto.all(photoId), listAllTags: () => s.tagList.all(), - // Collections 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: (photoId, colId) => s.colAdd.run(photoId, colId), + removePhotoFromCollection: (photoId, colId) => s.colRemove.run(photoId, colId), + getPhotoCollections: (photoId) => s.colOfPhoto.all(photoId), - // Duplicates getDupePairs: () => s.dupePairs.all(), dismissDuplicates: (ids) => { const ph = ids.map(() => '?').join(','); @@ -157,5 +173,18 @@ export function openDb(dbPath) { WHERE photo_id_a IN (${ph}) AND photo_id_b IN (${ph})` ).run(...ids, ...ids); }, + + 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, + started_at: active ? new Date().toISOString() : null, + updated_at: new Date().toISOString(), + }), }; } diff --git a/worker/index.js b/worker/index.js index 8cfa68c..f478e65 100644 --- a/worker/index.js +++ b/worker/index.js @@ -13,22 +13,31 @@ const db = openDb(DB_PATH); console.log(`[worker] watching ${path.resolve(PHOTO_DIR)}`); console.log(`[worker] db at ${path.resolve(DB_PATH)} (${db.count()} photos indexed)`); -// Serial queue: chokidar fires many 'add' events at once on startup; -// process them one at a time to keep memory and I/O pressure low. const queue = []; -let busy = false; +let busy = false; +let scanned = 0; +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(); try { await ingestFile(fp, db, POSTER_DIR); + scanned++; + db.setIngestStatus({ active: true, current: path.basename(fp), scanned, errors }); } catch (err) { + errors++; console.error(`[worker] ${path.basename(fp)}: ${err.message}`); + db.setIngestStatus({ active: true, current: path.basename(fp), scanned, errors }); } } + + db.setIngestStatus({ active: false, current: null, scanned, errors }); busy = false; } @@ -36,7 +45,7 @@ const watcher = chokidar.watch(PHOTO_DIR, { persistent: true, ignoreInitial: false, awaitWriteFinish: { stabilityThreshold: 2000, pollInterval: 100 }, - ignored: /(^|[/\\])\../, // skip dotfiles + ignored: /(^|[/\\])\../, }); watcher.on('add', (fp) => { @@ -46,8 +55,7 @@ watcher.on('add', (fp) => { }); watcher.on('unlink', (fp) => { - const resolved = path.resolve(fp); - db.removePhoto(resolved); + db.removePhoto(path.resolve(fp)); console.log(`[worker] removed ${path.basename(fp)}`); }); @@ -55,6 +63,7 @@ watcher.on('error', (err) => console.error('[worker] watcher error:', err)); process.on('SIGINT', () => { console.log('\n[worker] shutting down'); + db.setIngestStatus({ active: false, current: null, scanned, errors }); watcher.close(); process.exit(0); });