feat: map view with GPS extraction fix

Adds a new Map sidebar entry that plots photos by their EXIF GPS
coordinates on a clustered Leaflet map. While wiring this up, the
metadata extractor was reading unprefixed GPS keys that never exist
in `exiftool -G -j` output AND assumed coordinates were already
floats — every photo silently lost its GPS. The new extract_gps
helper handles Composite/EXIF group prefixes and parses DMS strings,
and lat/lon are stored as first-class indexed columns so the map
can query them without parsing exif_json on every request.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 23:44:29 +02:00
parent 9c9f5bd899
commit 7cf546af7a
17 changed files with 529 additions and 46 deletions

View File

@@ -62,6 +62,14 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
)
).scalar() or 0
with_gps_count = (
await db.execute(
select(func.count(Photo.id)).where(
not_discarded, Photo.latitude.is_not(None)
)
)
).scalar() or 0
duplicates_count = (
await db.execute(
select(func.count(Photo.id)).where(
@@ -96,6 +104,7 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
"all_photos": all_photos_count,
"rated": rated_count,
"colored": colored_count,
"with_gps": with_gps_count,
"duplicates": duplicates_count,
"discarded": discarded_count,
"total_photos": photo_count,
@@ -108,11 +117,23 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
async def trigger_scan():
"""Trigger full library re-scan"""
from app.tasks.scan import scan_all_source_roots
scan_all_source_roots.delay()
return {"status": "success", "message": "Library scan started"}
@router.post("/backfill-gps")
async def trigger_backfill_gps():
"""Re-run EXIF metadata extraction on every photo that's still missing
GPS coordinates. Useful after fixing the EXIF parser, or any time the
Map view looks emptier than expected. Returns immediately — work runs
on the Celery worker."""
from app.tasks.scan import backfill_gps
backfill_gps.delay()
return {"status": "success", "message": "GPS backfill queued"}
@router.get("/scan/status")
async def get_scan_status(db: AsyncSession = Depends(get_db)):
"""Get current scan status"""