From 4180e7866cd9b274315295a3233eb94c9f4260b6 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Wed, 4 Feb 2026 15:51:47 -0800 Subject: [PATCH] feat: extract and store GPS coords --- apps/worker/src/jobs.ts | 87 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/apps/worker/src/jobs.ts b/apps/worker/src/jobs.ts index 6fc1631..5edc117 100644 --- a/apps/worker/src/jobs.ts +++ b/apps/worker/src/jobs.ts @@ -271,6 +271,75 @@ function parseExifDate(dateStr: string | undefined): Date | null { return isNaN(date.getTime()) ? null : date; } +function parseGpsParts(parts: number[]): number | null { + if (parts.length === 0 || !Number.isFinite(parts[0])) return null; + const [deg, min, sec] = parts; + const sign = deg < 0 ? -1 : 1; + let value = Math.abs(deg); + if (Number.isFinite(min)) value += Math.abs(min) / 60; + if (Number.isFinite(sec)) value += Math.abs(sec) / 3600; + return sign * value; +} + +function parseGpsValue(value: unknown): number | null { + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return null; + const direct = Number(trimmed); + if (!Number.isNaN(direct)) return direct; + const parts = trimmed.match(/-?\d+(?:\.\d+)?/g); + if (!parts) return null; + return parseGpsParts(parts.map((part) => Number(part)).filter(Number.isFinite)); + } + + if (Array.isArray(value)) { + const parts = value + .map((part) => { + if (typeof part === "number") return part; + if (typeof part === "string") return Number(part); + return NaN; + }) + .filter(Number.isFinite); + return parseGpsParts(parts); + } + + return null; +} + +function applyRefSign(value: number, ref: unknown): number { + if (typeof ref !== "string") return value; + const normalized = ref.trim().toUpperCase(); + if (normalized === "S" || normalized === "W") return -Math.abs(value); + if (normalized === "N" || normalized === "E") return Math.abs(value); + return value; +} + +function parseGpsCoord( + value: unknown, + ref: unknown, + kind: "lat" | "lon", +): number | null { + const parsed = parseGpsValue(value); + if (parsed === null) return null; + const signed = applyRefSign(parsed, ref); + if (!Number.isFinite(signed)) return null; + if (kind === "lat") { + return signed >= -90 && signed <= 90 ? signed : null; + } + return signed >= -180 && signed <= 180 ? signed : null; +} + +function extractGps(tags: Record) { + const lat = parseGpsCoord(tags.GPSLatitude, tags.GPSLatitudeRef, "lat"); + const lon = parseGpsCoord(tags.GPSLongitude, tags.GPSLongitudeRef, "lon"); + if (lat === null || lon === null) return null; + return { lat, lon }; +} + function isPlausibleCaptureTs(date: Date) { const ts = date.getTime(); if (!Number.isFinite(ts)) return false; @@ -351,7 +420,9 @@ export async function handleProcessAsset(raw: unknown) { thumb_small_key: null, thumb_med_key: null, poster_key: null, - raw_tags_json: null + raw_tags_json: null, + gps_lat: null, + gps_lon: null }; let rawTags: Record = {}; let captureTs: Date | null = null; @@ -425,6 +496,11 @@ export async function handleProcessAsset(raw: unknown) { if (asset.media_type === "image") { rawTags = await tryReadExifTags(); maybeSetCaptureDateFromTags(rawTags); + const gps = extractGps(rawTags); + if (gps) { + updates.gps_lat = gps.lat; + updates.gps_lon = gps.lon; + } await applyObjectMtimeFallback(); @@ -470,6 +546,11 @@ export async function handleProcessAsset(raw: unknown) { } else if (asset.media_type === "video") { rawTags = await tryReadExifTags(); maybeSetCaptureDateFromTags(rawTags); + const gps = extractGps(rawTags); + if (gps) { + updates.gps_lat = gps.lat; + updates.gps_lon = gps.lon; + } const ffprobeOutput = await runCommand("ffprobe", [ "-v", @@ -583,7 +664,9 @@ export async function handleProcessAsset(raw: unknown) { "thumb_small_key", "thumb_med_key", "poster_key", - "raw_tags_json" + "raw_tags_json", + "gps_lat", + "gps_lon" )}, status = 'ready', error_message = null where id = ${asset.id} `;