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
+264
-25
No files matched your search
+11
-4
@@ -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 {}
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_photos_starred ON photos(starred)');
|
||||
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;
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -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 });
|
||||
|
||||
@@ -78,7 +86,9 @@ export async function ingestFile(filepath, db, posterDir, settings = {}) {
|
||||
phash,
|
||||
width,
|
||||
height,
|
||||
filesize: stat.size,
|
||||
filesize: stat.size,
|
||||
place_city: placeCity,
|
||||
place_country: placeCountry,
|
||||
});
|
||||
|
||||
console.log(`[ingest] ${path.basename(filepath)}`);
|
||||
|
||||
Reference in new issue
Block a user