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"""

View File

@@ -214,6 +214,36 @@ async def list_photos(
"pages": (total + per_page - 1) // per_page if total else 0,
}
@router.get("/map")
async def list_photos_with_gps(db: AsyncSession = Depends(get_db)):
"""Lightweight listing of every non-discarded photo that has GPS
coordinates, used by the Map view. Intentionally returns a flat list
(no pagination) with only the fields the map renderer needs, so even
large libraries serialize to a few MB at most. Declared *before*
``/{photo_id}`` so the literal path wins the FastAPI route match."""
result = await db.execute(
select(
Photo.id,
Photo.latitude,
Photo.longitude,
Photo.taken_at,
).where(
Photo.is_discarded.is_(False),
Photo.latitude.is_not(None),
Photo.longitude.is_not(None),
)
)
return [
{
"id": row.id,
"latitude": row.latitude,
"longitude": row.longitude,
"taken_at": row.taken_at.isoformat() if row.taken_at else None,
}
for row in result.all()
]
@router.get("/{photo_id}")
async def get_photo(
photo_id: str,