Add geotagging and location map v0.4
This commit is contained in:
1 parent
cbf0daf062
commit
e290bd1fff
12 files changed
+578
-45
No files matched your search
+199
-5
@@ -1,11 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
@@ -16,11 +21,29 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
APP_NAME = os.getenv("APP_NAME", "Photo Date Editor")
|
||||
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
|
||||
APP_VERSION = "0.3.0"
|
||||
APP_VERSION = "0.4.0"
|
||||
MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "150"))
|
||||
MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
|
||||
MAP_TILE_URL = os.getenv("MAP_TILE_URL", "https://tile.openstreetmap.org/{z}/{x}/{y}.png")
|
||||
MAP_ATTRIBUTION = os.getenv(
|
||||
"MAP_ATTRIBUTION",
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>',
|
||||
)
|
||||
DEFAULT_MAP_LAT = float(os.getenv("DEFAULT_MAP_LAT", "64.5"))
|
||||
DEFAULT_MAP_LON = float(os.getenv("DEFAULT_MAP_LON", "11.0"))
|
||||
DEFAULT_MAP_ZOOM = int(os.getenv("DEFAULT_MAP_ZOOM", "5"))
|
||||
GEOCODER_URL = os.getenv("GEOCODER_URL", "https://nominatim.openstreetmap.org/search")
|
||||
GEOCODER_USER_AGENT = os.getenv(
|
||||
"GEOCODER_USER_AGENT",
|
||||
f"{APP_NAME.replace(' ', '')}/{APP_VERSION} (+{APP_URL})",
|
||||
)
|
||||
|
||||
_geocode_lock = asyncio.Lock()
|
||||
_geocode_cache: dict[str, list[dict[str, Any]]] = {}
|
||||
_last_geocode_request = 0.0
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, LOG_LEVEL, logging.INFO),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
@@ -39,15 +62,113 @@ def health() -> dict[str, str]:
|
||||
|
||||
|
||||
@app.get("/api/config")
|
||||
def config() -> dict[str, str | int]:
|
||||
def config() -> dict[str, str | int | float]:
|
||||
return {
|
||||
"appName": APP_NAME,
|
||||
"appUrl": APP_URL,
|
||||
"version": APP_VERSION,
|
||||
"maxUploadMb": MAX_UPLOAD_MB,
|
||||
"mapTileUrl": MAP_TILE_URL,
|
||||
"mapAttribution": MAP_ATTRIBUTION,
|
||||
"defaultMapLat": DEFAULT_MAP_LAT,
|
||||
"defaultMapLon": DEFAULT_MAP_LON,
|
||||
"defaultMapZoom": DEFAULT_MAP_ZOOM,
|
||||
}
|
||||
|
||||
|
||||
|
||||
def _fetch_geocode_results(query: str) -> list[dict[str, Any]]:
|
||||
parameters = urllib.parse.urlencode(
|
||||
{
|
||||
"q": query,
|
||||
"format": "jsonv2",
|
||||
"addressdetails": "1",
|
||||
"limit": "5",
|
||||
}
|
||||
)
|
||||
request = urllib.request.Request(
|
||||
f"{GEOCODER_URL}?{parameters}",
|
||||
headers={
|
||||
"User-Agent": GEOCODER_USER_AGENT,
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
raw = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for item in raw if isinstance(raw, list) else []:
|
||||
try:
|
||||
latitude = float(item["lat"])
|
||||
longitude = float(item["lon"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
address = item.get("address") if isinstance(item.get("address"), dict) else {}
|
||||
results.append(
|
||||
{
|
||||
"label": str(item.get("display_name") or "").strip(),
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"type": str(item.get("type") or item.get("category") or "place"),
|
||||
"address": {
|
||||
key: str(address[key])
|
||||
for key in (
|
||||
"house_number",
|
||||
"road",
|
||||
"neighbourhood",
|
||||
"suburb",
|
||||
"city",
|
||||
"town",
|
||||
"village",
|
||||
"municipality",
|
||||
"county",
|
||||
"state",
|
||||
"postcode",
|
||||
"country",
|
||||
"country_code",
|
||||
)
|
||||
if address.get(key)
|
||||
},
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@app.get("/api/geocode")
|
||||
async def geocode(q: str) -> dict[str, list[dict[str, Any]]]:
|
||||
global _last_geocode_request
|
||||
|
||||
query = " ".join(q.split())
|
||||
if len(query) < 3:
|
||||
raise HTTPException(status_code=422, detail="Enter at least three characters to search.")
|
||||
if len(query) > 200:
|
||||
raise HTTPException(status_code=422, detail="The address search is too long.")
|
||||
|
||||
cache_key = query.casefold()
|
||||
if cache_key in _geocode_cache:
|
||||
return {"results": _geocode_cache[cache_key]}
|
||||
|
||||
async with _geocode_lock:
|
||||
if cache_key in _geocode_cache:
|
||||
return {"results": _geocode_cache[cache_key]}
|
||||
|
||||
delay = 1.0 - (time.monotonic() - _last_geocode_request)
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
try:
|
||||
results = await asyncio.to_thread(_fetch_geocode_results, query)
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Geocoding request failed: %s", exc)
|
||||
raise HTTPException(status_code=502, detail="The address search service could not be reached.") from exc
|
||||
finally:
|
||||
_last_geocode_request = time.monotonic()
|
||||
|
||||
if len(_geocode_cache) >= 200:
|
||||
_geocode_cache.pop(next(iter(_geocode_cache)))
|
||||
_geocode_cache[cache_key] = results
|
||||
return {"results": results}
|
||||
|
||||
def _normalise_datetime(
|
||||
precision: str,
|
||||
year: int,
|
||||
@@ -149,7 +270,20 @@ def _as_keywords(*values: Any) -> list[str]:
|
||||
return output
|
||||
|
||||
|
||||
def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, str]]:
|
||||
|
||||
def _signed_coordinate(value: Any, reference: Any, negative_letter: str) -> float | None:
|
||||
try:
|
||||
coordinate = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if coordinate < 0:
|
||||
return coordinate
|
||||
reference_text = _as_text(reference).upper()
|
||||
if reference_text == negative_letter or reference_text == "1":
|
||||
return -coordinate
|
||||
return coordinate
|
||||
|
||||
def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, Any]]:
|
||||
date_text = _as_text(
|
||||
_first_value(
|
||||
metadata,
|
||||
@@ -169,6 +303,11 @@ def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, st
|
||||
)
|
||||
)
|
||||
keywords = _as_keywords(metadata.get("Subject"), metadata.get("Keywords"))
|
||||
latitude = _signed_coordinate(metadata.get("GPSLatitude"), metadata.get("GPSLatitudeRef"), "S")
|
||||
longitude = _signed_coordinate(metadata.get("GPSLongitude"), metadata.get("GPSLongitudeRef"), "W")
|
||||
location_name = _as_text(
|
||||
_first_value(metadata, "Location", "LocationShownLocationName", "City")
|
||||
)
|
||||
|
||||
precision = "exact"
|
||||
time_mode = "known"
|
||||
@@ -209,14 +348,18 @@ def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, st
|
||||
"time": parsed_date.strftime("%H:%M:%S") if parsed_date else "12:00:00",
|
||||
"description": description,
|
||||
"keywords": ", ".join(keywords),
|
||||
"latitude": f"{latitude:.8f}" if latitude is not None else "",
|
||||
"longitude": f"{longitude:.8f}" if longitude is not None else "",
|
||||
"locationName": location_name,
|
||||
"locationDirty": False,
|
||||
}
|
||||
has_metadata = bool(parsed_date or description or keywords or marker)
|
||||
has_metadata = bool(parsed_date or description or keywords or marker or latitude is not None or longitude is not None or location_name)
|
||||
edited_by_app = bool(marker)
|
||||
return has_metadata, edited_by_app, values
|
||||
|
||||
|
||||
@app.post("/api/metadata")
|
||||
async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, bool | dict[str, str]]:
|
||||
async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, bool | dict[str, Any]]:
|
||||
filename = file.filename or "photo.jpg"
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix not in {".jpg", ".jpeg"}:
|
||||
@@ -230,6 +373,7 @@ async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, boo
|
||||
"exiftool",
|
||||
"-json",
|
||||
"-s",
|
||||
"-n",
|
||||
"-DateTimeOriginal",
|
||||
"-CreateDate",
|
||||
"-ModifyDate",
|
||||
@@ -240,6 +384,13 @@ async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, boo
|
||||
"-Caption-Abstract",
|
||||
"-Subject",
|
||||
"-Keywords",
|
||||
"-GPSLatitude",
|
||||
"-GPSLatitudeRef",
|
||||
"-GPSLongitude",
|
||||
"-GPSLongitudeRef",
|
||||
"-Location",
|
||||
"-LocationShownLocationName",
|
||||
"-City",
|
||||
str(temp_path),
|
||||
],
|
||||
capture_output=True,
|
||||
@@ -270,6 +421,10 @@ async def process_photo(
|
||||
time_value: Annotated[str | None, Form()] = None,
|
||||
description: Annotated[str, Form()] = "",
|
||||
keywords_json: Annotated[str, Form()] = "[]",
|
||||
gps_action: Annotated[str, Form()] = "preserve",
|
||||
latitude: Annotated[float | None, Form()] = None,
|
||||
longitude: Annotated[float | None, Form()] = None,
|
||||
location_name: Annotated[str, Form()] = "",
|
||||
) -> Response:
|
||||
filename = file.filename or "photo.jpg"
|
||||
suffix = Path(filename).suffix.lower()
|
||||
@@ -293,6 +448,14 @@ async def process_photo(
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=422, detail="Keywords must be a JSON array.") from exc
|
||||
|
||||
if gps_action not in {"preserve", "set", "clear"}:
|
||||
raise HTTPException(status_code=422, detail="Unsupported GPS action.")
|
||||
if gps_action == "set":
|
||||
if latitude is None or longitude is None:
|
||||
raise HTTPException(status_code=422, detail="Both latitude and longitude are required.")
|
||||
if not -90 <= latitude <= 90 or not -180 <= longitude <= 180:
|
||||
raise HTTPException(status_code=422, detail="The GPS coordinates are outside the valid range.")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="photo-date-editor-") as temp_dir:
|
||||
temp_path = Path(temp_dir) / f"working{suffix}"
|
||||
await _save_upload(file, temp_path)
|
||||
@@ -301,6 +464,7 @@ async def process_photo(
|
||||
"exiftool",
|
||||
"-overwrite_original",
|
||||
"-m",
|
||||
"-n",
|
||||
f"-EXIF:DateTimeOriginal={exif_datetime}",
|
||||
f"-EXIF:CreateDate={exif_datetime}",
|
||||
f"-EXIF:ModifyDate={exif_datetime}",
|
||||
@@ -330,6 +494,36 @@ async def process_photo(
|
||||
command.append(f"-XMP-dc:Subject+={keyword}")
|
||||
command.append(f"-IPTC:Keywords+={keyword}")
|
||||
|
||||
if gps_action == "clear":
|
||||
command.extend(
|
||||
[
|
||||
"-EXIF:GPSLatitude=",
|
||||
"-EXIF:GPSLatitudeRef=",
|
||||
"-EXIF:GPSLongitude=",
|
||||
"-EXIF:GPSLongitudeRef=",
|
||||
"-XMP-exif:GPSLatitude=",
|
||||
"-XMP-exif:GPSLongitude=",
|
||||
"-XMP-iptcCore:Location=",
|
||||
]
|
||||
)
|
||||
elif gps_action == "set" and latitude is not None and longitude is not None:
|
||||
latitude_ref = "N" if latitude >= 0 else "S"
|
||||
longitude_ref = "E" if longitude >= 0 else "W"
|
||||
command.extend(
|
||||
[
|
||||
f"-EXIF:GPSLatitude={abs(latitude):.8f}",
|
||||
f"-EXIF:GPSLatitudeRef={latitude_ref}",
|
||||
f"-EXIF:GPSLongitude={abs(longitude):.8f}",
|
||||
f"-EXIF:GPSLongitudeRef={longitude_ref}",
|
||||
f"-XMP-exif:GPSLatitude={latitude:.8f}",
|
||||
f"-XMP-exif:GPSLongitude={longitude:.8f}",
|
||||
"-XMP-iptcCore:Location=",
|
||||
]
|
||||
)
|
||||
clean_location_name = location_name.strip()
|
||||
if clean_location_name:
|
||||
command.append(f"-XMP-iptcCore:Location={clean_location_name}")
|
||||
|
||||
command.append(str(temp_path))
|
||||
|
||||
logger.info("Processing %s with precision %s", filename, precision)
|
||||
|
||||
Reference in new issue
Block a user