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:
1 parent
64f7906b22
commit
3429ec1d80
8 files changed
+261
-22
No files matched your search
+2
-2
@@ -10,7 +10,7 @@ import SettingsPanel from './components/SettingsPanel.jsx';
|
||||
import { lazy, Suspense } from 'react';
|
||||
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;
|
||||
|
||||
export default function App() {
|
||||
@@ -101,7 +101,7 @@ export default function App() {
|
||||
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 (
|
||||
<>
|
||||
|
||||
@@ -10,7 +10,7 @@ const json = (body) => ({
|
||||
});
|
||||
|
||||
// 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 });
|
||||
if (cursor) p.set('cursor', cursor);
|
||||
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 (collectionId) p.set('collectionId', collectionId);
|
||||
if (starred) p.set('starred', starred);
|
||||
if (city) p.set('city', city);
|
||||
return api(`/api/photos?${p}`);
|
||||
}
|
||||
|
||||
export function fetchDirBrowser(dirPath) {
|
||||
return api(`/api/fs/browse?path=${encodeURIComponent(dirPath)}`);
|
||||
}
|
||||
|
||||
export function fetchPhotoDetail(id) {
|
||||
return api(`/api/photos/${id}`);
|
||||
}
|
||||
|
||||
@@ -9,27 +9,27 @@ export default function FilterSidebar({ filters, onChange, stats, total }) {
|
||||
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 isAll = !isStarred && !selectedYear && !filters.tagId && !filters.collectionId;
|
||||
const isAll = !isStarred && !selectedYear && !filters.tagId && !filters.collectionId && !filters.city;
|
||||
|
||||
function selectAll() {
|
||||
onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '' });
|
||||
onChange({ q: filters.q, from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' });
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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 (
|
||||
@@ -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 */}
|
||||
{stats?.years?.length > 0 && (
|
||||
<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 (
|
||||
<button
|
||||
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' }}>
|
||||
{label}
|
||||
{sublabel && (
|
||||
<span style={{ marginLeft: 5, fontSize: 10, color: active ? '#4a6a8a' : '#2a2a2a' }}>
|
||||
{sublabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{count != null && (
|
||||
<span style={{ fontSize: 11, color: active ? '#5a7a9a' : '#333', flexShrink: 0 }}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import {
|
||||
fetchSettings, patchSettings,
|
||||
fetchWatchFolders, addWatchFolder, removeWatchFolder, toggleWatchFolder,
|
||||
clearDismissals,
|
||||
clearDismissals, fetchDirBrowser,
|
||||
} from '../api/photos.js';
|
||||
|
||||
export default function SettingsPanel({ onClose }) {
|
||||
@@ -12,6 +12,11 @@ export default function SettingsPanel({ onClose }) {
|
||||
const [saving, setSaving] = 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(() => {
|
||||
fetchSettings().then(setSettings);
|
||||
fetchWatchFolders().then(setFolders);
|
||||
@@ -38,6 +43,32 @@ export default function SettingsPanel({ onClose }) {
|
||||
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) {
|
||||
await removeWatchFolder(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',
|
||||
}}
|
||||
/>
|
||||
<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
|
||||
onClick={handleAddFolder}
|
||||
disabled={!newPath.trim()}
|
||||
@@ -158,6 +201,88 @@ export default function SettingsPanel({ onClose }) {
|
||||
Add
|
||||
</button>
|
||||
</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>
|
||||
|
||||
{/* ── Ingestion ── */}
|
||||
|
||||
+35
-4
@@ -1,6 +1,7 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { openDb } from '../worker/db.js';
|
||||
import { scan, getGroups } from './dedupe.js';
|
||||
@@ -37,6 +38,8 @@ function mapPhoto(p) {
|
||||
height: p.height,
|
||||
filesize: p.filesize,
|
||||
starred: p.starred === 1,
|
||||
place_city: p.place_city ?? null,
|
||||
place_country: p.place_country ?? null,
|
||||
thumb_url: posterUrl(p.thumb_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 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 joinParams = [];
|
||||
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 (to) { condParts.push('p.date_taken <= ?'); condParams.push(to); }
|
||||
if (starred === '1') condParts.push('p.starred = 1');
|
||||
if (city) { condParts.push('p.place_city = ?'); condParams.push(city); }
|
||||
|
||||
return {
|
||||
joinSql: joinParts.join(' '),
|
||||
@@ -106,10 +110,14 @@ app.get('/api/library/stats', (_req, res) => {
|
||||
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();
|
||||
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({
|
||||
total: db.count(),
|
||||
starred,
|
||||
years,
|
||||
cities,
|
||||
tags: db.listAllTags(),
|
||||
collections: db.listCollections(),
|
||||
});
|
||||
@@ -121,15 +129,16 @@ 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.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) => {
|
||||
const limit = Math.min(parseInt(req.query.limit ?? '100', 10), 500);
|
||||
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 = '';
|
||||
const cursorParams = [];
|
||||
@@ -318,6 +327,28 @@ app.post('/api/admin/clear-dismissals', (_req, res) => {
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
const distPath = path.resolve(__dirname, '../dist');
|
||||
|
||||
+10
-3
@@ -32,7 +32,9 @@ export function openDb(dbPath) {
|
||||
width INTEGER,
|
||||
height 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);
|
||||
@@ -96,7 +98,10 @@ export function openDb(dbPath) {
|
||||
|
||||
// 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 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_place_city ON photos(place_city)');
|
||||
|
||||
// Seed default settings
|
||||
const seedSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
|
||||
@@ -106,10 +111,12 @@ export function openDb(dbPath) {
|
||||
insert: db.prepare(`
|
||||
INSERT OR IGNORE INTO photos
|
||||
(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
|
||||
(@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'),
|
||||
remove: db.prepare('DELETE FROM photos WHERE filepath = ?'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import crypto from 'crypto';
|
||||
import sharp from 'sharp';
|
||||
import exifr from 'exifr';
|
||||
import { computeDHash } from './hash.js';
|
||||
import { reverseGeocode } from './geocode.js';
|
||||
|
||||
const SUPPORTED = new Set([
|
||||
'.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif',
|
||||
@@ -37,6 +38,13 @@ export async function ingestFile(filepath, db, posterDir, settings = {}) {
|
||||
const gpsLat = exif.latitude ?? 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);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
@@ -79,6 +87,8 @@ export async function ingestFile(filepath, db, posterDir, settings = {}) {
|
||||
width,
|
||||
height,
|
||||
filesize: stat.size,
|
||||
place_city: placeCity,
|
||||
place_country: placeCountry,
|
||||
});
|
||||
|
||||
console.log(`[ingest] ${path.basename(filepath)}`);
|
||||
|
||||
Reference in new issue
Block a user