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 (
+
+
+ {/* Search */}
+
+ onChange({ ...filters, q: e.target.value })}
+ style={{
+ width: '100%', background: '#1a1a1a', border: '1px solid #242424',
+ borderRadius: 7, color: '#ccc', fontSize: 12,
+ padding: '6px 10px', outline: 'none',
+ }}
+ />
+
+
+ {/* All Photos */}
+
+
+ {/* Favorites */}
+ {(stats?.starred > 0 || isStarred) && (
+
+ )}
+
+ {/* Date Taken */}
+ {stats?.years?.length > 0 && (
+
+ {(expanded.years ? stats.years : stats.years.slice(0, MAX_DEFAULT)).map(({ year, count }) => (
+ selectYear(year)}
+ label={year}
+ count={count}
+ indent
+ />
+ ))}
+ {stats.years.length > MAX_DEFAULT && (
+ toggle('years')} />
+ )}
+
+ )}
+
+ {/* Tags */}
+ {stats?.tags?.length > 0 && (
+
+ {(expanded.tags ? stats.tags : stats.tags.slice(0, MAX_DEFAULT)).map(tag => (
+ selectTag(tag.id)}
+ label={tag.name}
+ count={tag.count}
+ indent
+ />
+ ))}
+ {stats.tags.length > MAX_DEFAULT && (
+ toggle('tags')} />
+ )}
+
+ )}
+
+ {/* Collections */}
+ {stats?.collections?.length > 0 && (
+
+ {(expanded.collections ? stats.collections : stats.collections.slice(0, MAX_DEFAULT)).map(col => (
+ selectCollection(col.id)}
+ label={col.name}
+ count={col.photo_count}
+ indent
+ />
+ ))}
+ {stats.collections.length > MAX_DEFAULT && (
+ toggle('collections')} />
+ )}
+
+ )}
+
+ );
+}
+
+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);
});