feat: filter sidebar, ingest progress, starred photos

- Left filter sidebar: All Photos / Favorites / Date by Year / Tags / Collections
  with counts, See more/less toggles, and blue active-row highlight
- SSE ingest badge in header: pulsing dot + filename + indexed count during scan;
  auto-refreshes the library when a scan finishes
- Starred/Favorites: starred column on photos, star button in lightbox toolbar,
  Favorites row in sidebar, ?starred=1 filter on /api/photos
- GET /api/library/stats endpoint (totals, years, tags, collections)
- GET /api/ingest/stream SSE endpoint (polls ingest_status table every 1.5s)
- Worker writes ingest_status (active, current file, scanned count) to SQLite

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-17 17:04:01 -04:00
1 parent 4df32f9f32
commit f0e237ab2b
10 files changed
+481 -67

No files matched your search

+17
View File
@@ -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
}
]
}
+67 -26
View File
@@ -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 [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 ── */}
<header style={{
position: 'sticky', top: 0, zIndex: 100,
background: '#0f0f0f', borderBottom: '1px solid #1c1c1c',
@@ -96,7 +114,7 @@ export default function App() {
Luma
</span>
<nav style={{ display: 'flex', gap: 2, flex: 1 }}>
<nav style={{ display: 'flex', gap: 2 }}>
<Tab active={view === 'library'} onClick={() => setView('library')}>
Library
{total !== null && (
@@ -105,9 +123,7 @@ export default function App() {
</span>
)}
</Tab>
<Tab active={view === 'map'} onClick={() => setView('map')}>
Map
</Tab>
<Tab active={view === 'map'} onClick={() => setView('map')}>Map</Tab>
<Tab active={view === 'duplicates'} onClick={() => setView('duplicates')}>
Duplicates
{dupeCount > 0 && (
@@ -121,17 +137,39 @@ export default function App() {
)}
</Tab>
</nav>
{/* Ingest progress — right side */}
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-end' }}>
<IngestBadge onScanComplete={handleScanComplete} />
</div>
</header>
{/* ── Library ── */}
{/* ── Body (sidebar + content) ── */}
<div style={{ display: 'flex', alignItems: 'flex-start' }}>
{/* Sidebar — only in library view */}
{view === 'library' && (
<>
<FilterBar
<aside style={{
position: 'sticky', top: 52,
width: SIDEBAR_W, flexShrink: 0,
height: 'calc(100vh - 52px)', overflowY: 'auto',
borderRight: '1px solid #141414', background: '#0a0a0a',
}}>
<FilterSidebar
filters={filters}
onChange={setFilters}
tags={tags}
collections={collections}
stats={stats}
total={total}
/>
</aside>
)}
{/* Main content */}
<div style={{ flex: 1, minWidth: 0 }}>
{/* Library */}
{view === 'library' && (
<>
{error && (
<p style={{ color: '#f87171', padding: 24, textAlign: 'center', fontSize: 14 }}>
{error}
@@ -139,7 +177,7 @@ export default function App() {
)}
{photos.length === 0 && !loading && !error && (
<div style={{ textAlign: 'center', padding: '80px 24px', color: '#555' }}>
{filters.q || filters.from || filters.to || filters.tagId || filters.collectionId ? (
{hasFilter ? (
<p style={{ fontSize: 15 }}>No photos match these filters.</p>
) : (
<>
@@ -160,14 +198,14 @@ export default function App() {
</>
)}
{/* ── Map ── */}
{/* Map */}
{view === 'map' && (
<Suspense fallback={<p style={{ padding: 24, color: '#555', fontSize: 13 }}>Loading map…</p>}>
<MapView onOpenPhoto={(photo) => openLightbox([photo], 0)} />
</Suspense>
)}
{/* ── Duplicates ── */}
{/* Duplicates */}
{view === 'duplicates' && (
<DuplicatesView
onGroupCountChange={setDupeCount}
@@ -176,14 +214,17 @@ export default function App() {
}
/>
)}
</div>
</div>
{/* ── Lightbox (shared) ── */}
{/* Lightbox */}
{lbPhotos !== null && (
<Lightbox
photos={lbPhotos}
index={lbIndex}
onClose={() => { setLbPhotos(null); setLbIndex(null); }}
onChange={setLbIndex}
onStarChange={handleStarChange}
/>
)}
</>
+14 -6
View File
@@ -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');
+194
View File
@@ -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 (
<nav style={{ padding: '10px 0 24px', userSelect: 'none' }}>
{/* Search */}
<div style={{ padding: '0 14px 8px' }}>
<input
type="search"
placeholder="Search…"
value={filters.q}
onChange={e => 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',
}}
/>
</div>
{/* All Photos */}
<Row
active={isAll && !filters.q}
onClick={selectAll}
icon="⊞"
label="All Photos"
count={stats?.total ?? total}
/>
{/* Favorites */}
{(stats?.starred > 0 || isStarred) && (
<Row
active={isStarred}
onClick={selectStarred}
icon="★"
iconColor="#f5c518"
label="Favorites"
count={stats?.starred}
/>
)}
{/* Date Taken */}
{stats?.years?.length > 0 && (
<Section title="Date Taken">
{(expanded.years ? stats.years : stats.years.slice(0, MAX_DEFAULT)).map(({ year, count }) => (
<Row
key={year}
active={selectedYear === year}
onClick={() => selectYear(year)}
label={year}
count={count}
indent
/>
))}
{stats.years.length > MAX_DEFAULT && (
<SeeMore expanded={expanded.years} onClick={() => toggle('years')} />
)}
</Section>
)}
{/* Tags */}
{stats?.tags?.length > 0 && (
<Section title="Tags">
{(expanded.tags ? stats.tags : stats.tags.slice(0, MAX_DEFAULT)).map(tag => (
<Row
key={tag.id}
active={filters.tagId === String(tag.id)}
onClick={() => selectTag(tag.id)}
label={tag.name}
count={tag.count}
indent
/>
))}
{stats.tags.length > MAX_DEFAULT && (
<SeeMore expanded={expanded.tags} onClick={() => toggle('tags')} />
)}
</Section>
)}
{/* Collections */}
{stats?.collections?.length > 0 && (
<Section title="Collections">
{(expanded.collections ? stats.collections : stats.collections.slice(0, MAX_DEFAULT)).map(col => (
<Row
key={col.id}
active={filters.collectionId === String(col.id)}
onClick={() => selectCollection(col.id)}
label={col.name}
count={col.photo_count}
indent
/>
))}
{stats.collections.length > MAX_DEFAULT && (
<SeeMore expanded={expanded.collections} onClick={() => toggle('collections')} />
)}
</Section>
)}
</nav>
);
}
function Section({ title, children }) {
return (
<div style={{ marginTop: 16 }}>
<div style={{
fontSize: 10, fontWeight: 700, letterSpacing: '0.08em',
color: '#3a3a3a', textTransform: 'uppercase',
padding: '0 16px 4px',
}}>
{title}
</div>
{children}
</div>
);
}
function Row({ active, onClick, icon, iconColor, label, count, indent }) {
return (
<button
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 8,
width: '100%', padding: `5px ${indent ? '16px' : '14px'}`,
background: active ? '#1e2a3a' : 'transparent',
border: 'none', cursor: 'pointer',
color: active ? '#9fc8ff' : '#aaa',
fontSize: 13, textAlign: 'left',
borderLeft: active ? '2px solid #4d9eff' : '2px solid transparent',
}}
>
{icon && (
<span style={{ fontSize: 12, flexShrink: 0, color: iconColor ?? 'inherit', lineHeight: 1 }}>
{icon}
</span>
)}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label}
</span>
{count != null && (
<span style={{ fontSize: 11, color: active ? '#5a7a9a' : '#333', flexShrink: 0 }}>
{Number(count).toLocaleString()}
</span>
)}
</button>
);
}
function SeeMore({ expanded, onClick }) {
return (
<button
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '3px 16px 3px 20px',
background: 'transparent', border: 'none',
color: '#4d9eff', cursor: 'pointer', fontSize: 12,
}}
>
{expanded ? 'See less ▲' : 'See more ▼'}
</button>
);
}
+53
View File
@@ -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 (
<div style={{
display: 'flex', alignItems: 'center', gap: 7,
fontSize: 12, color: '#888',
overflow: 'hidden', maxWidth: 340,
}}>
<span style={{
width: 7, height: 7, borderRadius: '50%',
background: '#4d9eff', flexShrink: 0,
animation: 'pulse-dot 1.4s ease-in-out infinite',
}} />
<span style={{ whiteSpace: 'nowrap', color: '#aaa' }}>
Scanning · {status.scanned.toLocaleString()} indexed
</span>
{status.current && (
<span style={{
color: '#444', overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontSize: 11,
}}>
{status.current}
</span>
)}
</div>
);
}
+20 -1
View File
@@ -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 }) {
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 }}
>
<IconBtn
onClick={async () => {
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 }}
>
★
</IconBtn>
<IconBtn
onClick={() => setShowInfo(s => !s)}
active={showInfo}
+5
View File
@@ -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;
+55 -16
View File
@@ -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 = [];
@@ -66,6 +65,7 @@ function buildFilter(q, from, to, tagId, collectionId) {
}
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(' '),
@@ -78,27 +78,59 @@ 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 = '';
const cursorParams = [];
if (cursor) {
@@ -106,9 +138,7 @@ app.get('/api/photos', (req, res) => {
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,7 +148,6 @@ 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);
@@ -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()));
+42 -13
View File
@@ -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(
'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,19 +146,16 @@ 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);
@@ -148,7 +165,6 @@ export function openDb(dbPath) {
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(),
}),
};
}
+14 -5
View File
@@ -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 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);
});