feat: places (reverse geocoding) + folder browser

Places:
- worker/geocode.js: Nominatim reverse geocoder, 1 req/sec rate limit,
  in-memory cache keyed to 3-decimal-place coordinates
- Ingest calls reverseGeocode() for any photo with GPS data; stores
  place_city and place_country on the photo row
- DB migrations add place_city / place_country columns + index on place_city
- Stats API returns cities grouped by count
- buildFilter() supports ?city= parameter
- FilterSidebar shows a Places section with city list + country sublabel

Folder browser:
- GET /api/fs/browse?path=… lists subdirectories (path-normalized to prevent traversal)
- Settings panel: 📁 button opens inline directory browser below the add
  input — navigate with up arrow / directory clicks, Select fills the input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-18 08:27:41 -04:00
1 parent 64f7906b22
commit 3429ec1d80
8 files changed
+261 -22

No files matched your search

+2 -2
View File
@@ -10,7 +10,7 @@ import SettingsPanel from './components/SettingsPanel.jsx';
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from 'react';
const MapView = lazy(() => import('./components/MapView.jsx')); const MapView = lazy(() => import('./components/MapView.jsx'));
const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '' }; const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' };
const SIDEBAR_W = 210; const SIDEBAR_W = 210;
export default function App() { export default function App() {
@@ -101,7 +101,7 @@ export default function App() {
loadStats(); loadStats();
} }
const hasFilter = filters.q || filters.from || filters.to || filters.tagId || filters.collectionId || filters.starred; const hasFilter = filters.q || filters.from || filters.to || filters.tagId || filters.collectionId || filters.starred || filters.city;
return ( return (
<> <>
+6 -1
View File
@@ -10,7 +10,7 @@ const json = (body) => ({
}); });
// Library // Library
export function fetchPhotos({ cursor, limit = 100, q, from, to, tagId, collectionId, starred } = {}) { export function fetchPhotos({ cursor, limit = 100, q, from, to, tagId, collectionId, starred, city } = {}) {
const p = new URLSearchParams({ limit }); const p = new URLSearchParams({ limit });
if (cursor) p.set('cursor', cursor); if (cursor) p.set('cursor', cursor);
if (q) p.set('q', q); if (q) p.set('q', q);
@@ -19,9 +19,14 @@ export function fetchPhotos({ cursor, limit = 100, q, from, to, tagId, collectio
if (tagId) p.set('tagId', tagId); if (tagId) p.set('tagId', tagId);
if (collectionId) p.set('collectionId', collectionId); if (collectionId) p.set('collectionId', collectionId);
if (starred) p.set('starred', starred); if (starred) p.set('starred', starred);
if (city) p.set('city', city);
return api(`/api/photos?${p}`); return api(`/api/photos?${p}`);
} }
export function fetchDirBrowser(dirPath) {
return api(`/api/fs/browse?path=${encodeURIComponent(dirPath)}`);
}
export function fetchPhotoDetail(id) { export function fetchPhotoDetail(id) {
return api(`/api/photos/${id}`); return api(`/api/photos/${id}`);
} }
+36 -11
View File
@@ -9,27 +9,27 @@ export default function FilterSidebar({ filters, onChange, stats, total }) {
setExpanded(e => ({ ...e, [section]: !e[section] })); setExpanded(e => ({ ...e, [section]: !e[section] }));
} }
// Derive active sidebar selection const selectedYear = (filters.from && filters.to) ? filters.from.slice(0, 4) : null;
const selectedYear = (filters.from && filters.to)
? filters.from.slice(0, 4)
: null;
const isStarred = filters.starred === '1'; const isStarred = filters.starred === '1';
const isAll = !isStarred && !selectedYear && !filters.tagId && !filters.collectionId; const isAll = !isStarred && !selectedYear && !filters.tagId && !filters.collectionId && !filters.city;
function selectAll() { function selectAll() {
onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '' }); onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' });
} }
function selectStarred() { function selectStarred() {
onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '1' }); onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '1', city: '' });
} }
function selectYear(year) { function selectYear(year) {
onChange({ q: filters.q, from: `${year}-01-01`, to: `${year}-12-31`, tagId: '', collectionId: '', starred: '' }); onChange({ q: filters.q, from: `${year}-01-01`, to: `${year}-12-31`, tagId: '', collectionId: '', starred: '', city: '' });
} }
function selectTag(id) { function selectTag(id) {
onChange({ q: filters.q, from: '', to: '', tagId: String(id), collectionId: '', starred: '' }); onChange({ q: filters.q, from: '', to: '', tagId: String(id), collectionId: '', starred: '', city: '' });
} }
function selectCollection(id) { function selectCollection(id) {
onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: String(id), starred: '' }); onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: String(id), starred: '', city: '' });
}
function selectCity(city) {
onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '', city });
} }
return ( return (
@@ -71,6 +71,26 @@ export default function FilterSidebar({ filters, onChange, stats, total }) {
/> />
)} )}
{/* Places */}
{stats?.cities?.length > 0 && (
<Section title="Places">
{(expanded.cities ? stats.cities : stats.cities.slice(0, MAX_DEFAULT)).map(({ city, country, count }) => (
<Row
key={city}
active={filters.city === city}
onClick={() => selectCity(city)}
label={city}
sublabel={country}
count={count}
indent
/>
))}
{stats.cities.length > MAX_DEFAULT && (
<SeeMore expanded={expanded.cities} onClick={() => toggle('cities')} />
)}
</Section>
)}
{/* Date Taken */} {/* Date Taken */}
{stats?.years?.length > 0 && ( {stats?.years?.length > 0 && (
<Section title="Date Taken"> <Section title="Date Taken">
@@ -146,7 +166,7 @@ function Section({ title, children }) {
); );
} }
function Row({ active, onClick, icon, iconColor, label, count, indent }) { function Row({ active, onClick, icon, iconColor, label, sublabel, count, indent }) {
return ( return (
<button <button
onClick={onClick} onClick={onClick}
@@ -167,6 +187,11 @@ function Row({ active, onClick, icon, iconColor, label, count, indent }) {
)} )}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label} {label}
{sublabel && (
<span style={{ marginLeft: 5, fontSize: 10, color: active ? '#4a6a8a' : '#2a2a2a' }}>
{sublabel}
</span>
)}
</span> </span>
{count != null && ( {count != null && (
<span style={{ fontSize: 11, color: active ? '#5a7a9a' : '#333', flexShrink: 0 }}> <span style={{ fontSize: 11, color: active ? '#5a7a9a' : '#333', flexShrink: 0 }}>
+126 -1
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { import {
fetchSettings, patchSettings, fetchSettings, patchSettings,
fetchWatchFolders, addWatchFolder, removeWatchFolder, toggleWatchFolder, fetchWatchFolders, addWatchFolder, removeWatchFolder, toggleWatchFolder,
clearDismissals, clearDismissals, fetchDirBrowser,
} from '../api/photos.js'; } from '../api/photos.js';
export default function SettingsPanel({ onClose }) { export default function SettingsPanel({ onClose }) {
@@ -12,6 +12,11 @@ export default function SettingsPanel({ onClose }) {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [cleared, setCleared] = useState(false); const [cleared, setCleared] = useState(false);
// Folder browser
const [browseOpen, setBrowseOpen] = useState(false);
const [browseData, setBrowseData] = useState(null); // { path, parent, dirs }
const [browseLoading, setBrowseLoading] = useState(false);
useEffect(() => { useEffect(() => {
fetchSettings().then(setSettings); fetchSettings().then(setSettings);
fetchWatchFolders().then(setFolders); fetchWatchFolders().then(setFolders);
@@ -38,6 +43,32 @@ export default function SettingsPanel({ onClose }) {
setNewPath(''); setNewPath('');
} }
async function openBrowser() {
setBrowseOpen(true);
setBrowseLoading(true);
try {
const data = await fetchDirBrowser('/photos');
setBrowseData(data);
} catch {
setBrowseData({ path: '/', parent: null, dirs: [] });
}
setBrowseLoading(false);
}
async function browseInto(dirPath) {
setBrowseLoading(true);
try {
const data = await fetchDirBrowser(dirPath);
setBrowseData(data);
} catch {}
setBrowseLoading(false);
}
function selectBrowsed() {
if (browseData?.path) setNewPath(browseData.path);
setBrowseOpen(false);
}
async function handleRemoveFolder(id) { async function handleRemoveFolder(id) {
await removeWatchFolder(id); await removeWatchFolder(id);
setFolders(f => f.filter(x => x.id !== id)); setFolders(f => f.filter(x => x.id !== id));
@@ -144,6 +175,18 @@ export default function SettingsPanel({ onClose }) {
padding: '5px 8px', outline: 'none', fontFamily: 'monospace', padding: '5px 8px', outline: 'none', fontFamily: 'monospace',
}} }}
/> />
<button
onClick={openBrowser}
title="Browse filesystem"
style={{
background: browseOpen ? '#1e2f45' : '#181818',
border: '1px solid ' + (browseOpen ? '#2a4a6a' : '#222'),
borderRadius: 5, color: browseOpen ? '#7ab8f5' : '#555',
cursor: 'pointer', padding: '5px 9px', fontSize: 13,
}}
>
📁
</button>
<button <button
onClick={handleAddFolder} onClick={handleAddFolder}
disabled={!newPath.trim()} disabled={!newPath.trim()}
@@ -158,6 +201,88 @@ export default function SettingsPanel({ onClose }) {
Add Add
</button> </button>
</div> </div>
{/* Inline folder browser */}
{browseOpen && (
<div style={{
margin: '4px 18px 8px',
background: '#0d0d0d', border: '1px solid #222',
borderRadius: 6, overflow: 'hidden',
}}>
{/* Breadcrumb + up */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 10px', borderBottom: '1px solid #1a1a1a',
background: '#131313',
}}>
{browseData?.parent !== null && browseData?.parent !== undefined && (
<button
onClick={() => browseInto(browseData.parent)}
style={{
background: 'none', border: 'none', color: '#4d9eff',
cursor: 'pointer', fontSize: 14, lineHeight: 1, padding: 0, flexShrink: 0,
}}
title="Up"
>↑</button>
)}
<span style={{
flex: 1, fontSize: 11, color: '#666', fontFamily: 'monospace',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
direction: 'rtl', textAlign: 'left',
}}>
{browseLoading ? '…' : (browseData?.path ?? '')}
</span>
<button
onClick={selectBrowsed}
title="Use this path"
style={{
background: '#1e2f45', border: '1px solid #2a4a6a',
borderRadius: 4, color: '#7ab8f5',
cursor: 'pointer', fontSize: 11, padding: '2px 8px', flexShrink: 0,
}}
>
Select
</button>
<button
onClick={() => setBrowseOpen(false)}
style={{
background: 'none', border: 'none', color: '#444',
cursor: 'pointer', fontSize: 14, lineHeight: 1, padding: 0, flexShrink: 0,
}}
>✕</button>
</div>
{/* Directory list */}
<div style={{ maxHeight: 180, overflowY: 'auto' }}>
{!browseLoading && browseData?.dirs?.length === 0 && (
<p style={{ padding: '10px 12px', color: '#333', fontSize: 12, margin: 0 }}>
No subdirectories
</p>
)}
{browseData?.dirs?.map(name => {
const full = (browseData.path === '/' ? '' : browseData.path) + '/' + name;
return (
<button
key={name}
onClick={() => browseInto(full)}
style={{
display: 'flex', alignItems: 'center', gap: 7,
width: '100%', padding: '5px 10px',
background: 'none', border: 'none', cursor: 'pointer',
color: '#bbb', fontSize: 12, textAlign: 'left',
fontFamily: 'monospace',
}}
onMouseEnter={e => e.currentTarget.style.background = '#1a1a1a'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
>
<span style={{ color: '#4d9eff', flexShrink: 0 }}>📁</span>
{name}
</button>
);
})}
</div>
</div>
)}
</Section> </Section>
{/* ── Ingestion ── */} {/* ── Ingestion ── */}
+35 -4
View File
@@ -1,6 +1,7 @@
import 'dotenv/config'; import 'dotenv/config';
import express from 'express'; import express from 'express';
import path from 'path'; import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { openDb } from '../worker/db.js'; import { openDb } from '../worker/db.js';
import { scan, getGroups } from './dedupe.js'; import { scan, getGroups } from './dedupe.js';
@@ -37,6 +38,8 @@ function mapPhoto(p) {
height: p.height, height: p.height,
filesize: p.filesize, filesize: p.filesize,
starred: p.starred === 1, starred: p.starred === 1,
place_city: p.place_city ?? null,
place_country: p.place_country ?? null,
thumb_url: posterUrl(p.thumb_path), thumb_url: posterUrl(p.thumb_path),
preview_url: posterUrl(p.preview_path), preview_url: posterUrl(p.preview_path),
}; };
@@ -45,7 +48,7 @@ function mapPhoto(p) {
function badRequest(res, msg) { res.status(400).json({ error: msg }); } function badRequest(res, msg) { res.status(400).json({ error: msg }); }
function notFound(res) { res.status(404).json({ error: 'not found' }); } function notFound(res) { res.status(404).json({ error: 'not found' }); }
function buildFilter(q, from, to, tagId, collectionId, starred) { function buildFilter(q, from, to, tagId, collectionId, starred, city) {
const joinParts = []; const joinParts = [];
const joinParams = []; const joinParams = [];
const condParts = []; const condParts = [];
@@ -66,6 +69,7 @@ function buildFilter(q, from, to, tagId, collectionId, starred) {
if (from) { condParts.push('p.date_taken >= ?'); condParams.push(from); } if (from) { condParts.push('p.date_taken >= ?'); condParams.push(from); }
if (to) { condParts.push('p.date_taken <= ?'); condParams.push(to); } if (to) { condParts.push('p.date_taken <= ?'); condParams.push(to); }
if (starred === '1') condParts.push('p.starred = 1'); if (starred === '1') condParts.push('p.starred = 1');
if (city) { condParts.push('p.place_city = ?'); condParams.push(city); }
return { return {
joinSql: joinParts.join(' '), joinSql: joinParts.join(' '),
@@ -106,10 +110,14 @@ app.get('/api/library/stats', (_req, res) => {
const years = db.raw.prepare( 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" "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(); ).all();
const cities = db.raw.prepare(
"SELECT place_city AS city, place_country AS country, COUNT(*) AS count FROM photos WHERE place_city IS NOT NULL GROUP BY place_city ORDER BY count DESC LIMIT 100"
).all();
res.json({ res.json({
total: db.count(), total: db.count(),
starred, starred,
years, years,
cities,
tags: db.listAllTags(), tags: db.listAllTags(),
collections: db.listCollections(), collections: db.listCollections(),
}); });
@@ -121,15 +129,16 @@ app.get('/api/health', (_req, res) => res.json({ ok: true, photos: db.count() })
const SELECT_COLS = ` const SELECT_COLS = `
p.id, p.thumb_path, p.preview_path, p.date_taken, p.date_imported, 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.starred p.camera_model, p.gps_lat, p.gps_lon, p.width, p.height, p.filesize,
p.starred, p.place_city, p.place_country
`; `;
app.get('/api/photos', (req, res) => { app.get('/api/photos', (req, res) => {
const limit = Math.min(parseInt(req.query.limit ?? '100', 10), 500); const limit = Math.min(parseInt(req.query.limit ?? '100', 10), 500);
const cursor = req.query.cursor ?? null; const cursor = req.query.cursor ?? null;
const { q, from, to, tagId, collectionId, starred } = req.query; const { q, from, to, tagId, collectionId, starred, city } = req.query;
const { joinSql, whereSql, params } = buildFilter(q, from, to, tagId, collectionId, starred); const { joinSql, whereSql, params } = buildFilter(q, from, to, tagId, collectionId, starred, city);
let cursorSql = ''; let cursorSql = '';
const cursorParams = []; const cursorParams = [];
@@ -318,6 +327,28 @@ app.post('/api/admin/clear-dismissals', (_req, res) => {
res.sendStatus(204); res.sendStatus(204);
}); });
// ── Filesystem browser ────────────────────────────────────────────────────────
app.get('/api/fs/browse', (req, res) => {
const raw = (req.query.path ?? '/photos').replace(/\0/g, '');
const dir = path.resolve('/', raw); // normalize — prevents traversal via ../../
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
return res.status(404).json({ error: 'not a directory' });
}
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return res.status(403).json({ error: 'permission denied' });
}
const dirs = entries
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
.map(e => e.name)
.sort((a, b) => a.localeCompare(b));
const parent = dir === '/' ? null : path.dirname(dir);
res.json({ path: dir, parent, dirs });
});
// ── SPA fallback ────────────────────────────────────────────────────────────── // ── SPA fallback ──────────────────────────────────────────────────────────────
const distPath = path.resolve(__dirname, '../dist'); const distPath = path.resolve(__dirname, '../dist');
+10 -3
View File
@@ -32,7 +32,9 @@ export function openDb(dbPath) {
width INTEGER, width INTEGER,
height INTEGER, height INTEGER,
filesize INTEGER, filesize INTEGER,
starred INTEGER NOT NULL DEFAULT 0 starred INTEGER NOT NULL DEFAULT 0,
place_city TEXT,
place_country TEXT
); );
CREATE INDEX IF NOT EXISTS idx_photos_date_taken ON photos(date_taken); CREATE INDEX IF NOT EXISTS idx_photos_date_taken ON photos(date_taken);
@@ -96,7 +98,10 @@ export function openDb(dbPath) {
// Column migrations — run before any indexes that depend on the new column // Column migrations — run before any indexes that depend on the new column
try { db.exec('ALTER TABLE photos ADD COLUMN starred INTEGER NOT NULL DEFAULT 0'); } catch {} try { db.exec('ALTER TABLE photos ADD COLUMN starred INTEGER NOT NULL DEFAULT 0'); } catch {}
try { db.exec('ALTER TABLE photos ADD COLUMN place_city TEXT'); } catch {}
try { db.exec('ALTER TABLE photos ADD COLUMN place_country TEXT'); } catch {}
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_starred ON photos(starred)'); db.exec('CREATE INDEX IF NOT EXISTS idx_photos_starred ON photos(starred)');
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_place_city ON photos(place_city)');
// Seed default settings // Seed default settings
const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
@@ -106,10 +111,12 @@ export function openDb(dbPath) {
insert: db.prepare(` insert: db.prepare(`
INSERT OR IGNORE INTO photos INSERT OR IGNORE INTO photos
(filepath, thumb_path, preview_path, date_taken, date_imported, (filepath, thumb_path, preview_path, date_taken, date_imported,
camera_model, gps_lat, gps_lon, phash, width, height, filesize) camera_model, gps_lat, gps_lon, phash, width, height, filesize,
place_city, place_country)
VALUES VALUES
(@filepath, @thumb_path, @preview_path, @date_taken, @date_imported, (@filepath, @thumb_path, @preview_path, @date_taken, @date_imported,
@camera_model, @gps_lat, @gps_lon, @phash, @width, @height, @filesize) @camera_model, @gps_lat, @gps_lon, @phash, @width, @height, @filesize,
@place_city, @place_country)
`), `),
exists: db.prepare('SELECT 1 FROM photos WHERE filepath = ? LIMIT 1'), exists: db.prepare('SELECT 1 FROM photos WHERE filepath = ? LIMIT 1'),
remove: db.prepare('DELETE FROM photos WHERE filepath = ?'), remove: db.prepare('DELETE FROM photos WHERE filepath = ?'),
+36
View File
@@ -0,0 +1,36 @@
const cache = new Map();
let lastCallMs = 0;
export async function reverseGeocode(lat, lon) {
const key = `${lat.toFixed(3)},${lon.toFixed(3)}`;
if (cache.has(key)) return cache.get(key);
// Nominatim ToS: max 1 request/second
const wait = 1100 - (Date.now() - lastCallMs);
if (wait > 0) await new Promise(r => setTimeout(r, wait));
lastCallMs = Date.now();
try {
const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10`;
const res = await fetch(url, {
headers: { 'User-Agent': 'Luma/1.0 self-hosted-photo-manager' },
signal: AbortSignal.timeout(8000),
});
if (!res.ok) throw new Error(res.status);
const data = await res.json();
const place = {
city: data.address?.city
?? data.address?.town
?? data.address?.village
?? data.address?.county
?? null,
country: data.address?.country ?? null,
};
cache.set(key, place);
return place;
} catch (err) {
console.warn(`[geocode] ${lat.toFixed(4)},${lon.toFixed(4)}: ${err.message}`);
cache.set(key, null);
return null;
}
}
+10
View File
@@ -4,6 +4,7 @@ import crypto from 'crypto';
import sharp from 'sharp'; import sharp from 'sharp';
import exifr from 'exifr'; import exifr from 'exifr';
import { computeDHash } from './hash.js'; import { computeDHash } from './hash.js';
import { reverseGeocode } from './geocode.js';
const SUPPORTED = new Set([ const SUPPORTED = new Set([
'.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif', '.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif',
@@ -37,6 +38,13 @@ export async function ingestFile(filepath, db, posterDir, settings = {}) {
const gpsLat = exif.latitude ?? null; const gpsLat = exif.latitude ?? null;
const gpsLon = exif.longitude ?? null; const gpsLon = exif.longitude ?? null;
let placeCity = null, placeCountry = null;
if (gpsLat !== null && gpsLon !== null) {
const place = await reverseGeocode(gpsLat, gpsLon);
placeCity = place?.city ?? null;
placeCountry = place?.country ?? null;
}
const { dir, thumb, preview } = posterPaths(filepath, posterDir, dateTaken ?? stat.mtime); const { dir, thumb, preview } = posterPaths(filepath, posterDir, dateTaken ?? stat.mtime);
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
@@ -79,6 +87,8 @@ export async function ingestFile(filepath, db, posterDir, settings = {}) {
width, width,
height, height,
filesize: stat.size, filesize: stat.size,
place_city: placeCity,
place_country: placeCountry,
}); });
console.log(`[ingest] ${path.basename(filepath)}`); console.log(`[ingest] ${path.basename(filepath)}`);