Initial JPEG photo metadata editor v0.1

This commit is contained in:
dmo committed 2026-07-23 01:10:49 +02:00
1 parent cdc206680c
commit 8738b8c814
12 files changed
+1222

No files matched your search

+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
import json
import logging
import os
import subprocess
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Annotated
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
APP_NAME = os.getenv("APP_NAME", "Photo Date Editor")
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
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()
logging.basicConfig(
level=getattr(logging, LOG_LEVEL, logging.INFO),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger("photo-date-editor")
BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"
app = FastAPI(title=APP_NAME, docs_url=None, redoc_url=None)
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/config")
def config() -> dict[str, str | int]:
return {
"appName": APP_NAME,
"appUrl": APP_URL,
"maxUploadMb": MAX_UPLOAD_MB,
}
def _normalise_datetime(
precision: str,
year: int,
month: int | None,
day: int | None,
time_value: str | None,
) -> tuple[str, str, str]:
"""Return EXIF datetime, XMP reduced-precision date, and a readable precision label."""
if not 1800 <= year <= 2200:
raise HTTPException(status_code=422, detail="Year must be between 1800 and 2200.")
precision_labels = {
"exact": "Exact date",
"month": "Month and year",
"year": "Year only",
"approximate": "Approximate year",
}
if precision not in precision_labels:
raise HTTPException(status_code=422, detail="Unsupported date precision.")
if precision == "exact":
if month is None or day is None:
raise HTTPException(status_code=422, detail="Exact dates require month and day.")
effective_month = month
effective_day = day
xmp_date = f"{year:04d}-{month:02d}-{day:02d}"
elif precision == "month":
if month is None:
raise HTTPException(status_code=422, detail="Month-and-year dates require a month.")
effective_month = month
effective_day = 1
xmp_date = f"{year:04d}-{month:02d}"
else:
effective_month = 1
effective_day = 1
xmp_date = f"{year:04d}"
effective_time = time_value.strip() if time_value else "12:00:00"
try:
parsed = datetime.strptime(
f"{year:04d}-{effective_month:02d}-{effective_day:02d} {effective_time}",
"%Y-%m-%d %H:%M:%S",
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"Invalid date or time: {exc}") from exc
exif_datetime = parsed.strftime("%Y:%m:%d %H:%M:%S")
return exif_datetime, xmp_date, precision_labels[precision]
async def _save_upload(upload: UploadFile, destination: Path) -> None:
total = 0
with destination.open("wb") as output:
while chunk := await upload.read(1024 * 1024):
total += len(chunk)
if total > MAX_UPLOAD_BYTES:
raise HTTPException(
status_code=413,
detail=f"Image is larger than the configured {MAX_UPLOAD_MB} MB limit.",
)
output.write(chunk)
with destination.open("rb") as source:
signature = source.read(3)
if signature[:2] != b"\xff\xd8":
raise HTTPException(status_code=415, detail="The uploaded file is not a valid JPEG image.")
@app.post("/api/process")
async def process_photo(
file: Annotated[UploadFile, File(...)],
precision: Annotated[str, Form(...)],
year: Annotated[int, Form(...)],
month: Annotated[int | None, Form()] = None,
day: Annotated[int | None, Form()] = None,
time_value: Annotated[str | None, Form()] = None,
description: Annotated[str, Form()] = "",
keywords_json: Annotated[str, Form()] = "[]",
) -> Response:
filename = file.filename or "photo.jpg"
suffix = Path(filename).suffix.lower()
if suffix not in {".jpg", ".jpeg"}:
raise HTTPException(status_code=415, detail="This version supports JPG and JPEG files only.")
exif_datetime, xmp_date, precision_label = _normalise_datetime(
precision=precision,
year=year,
month=month,
day=day,
time_value=time_value,
)
try:
raw_keywords = json.loads(keywords_json)
if not isinstance(raw_keywords, list):
raise ValueError
keywords = [str(value).strip() for value in raw_keywords if str(value).strip()]
except (json.JSONDecodeError, ValueError) as exc:
raise HTTPException(status_code=422, detail="Keywords must be a JSON array.") from exc
with tempfile.TemporaryDirectory(prefix="photo-date-editor-") as temp_dir:
temp_path = Path(temp_dir) / f"working{suffix}"
await _save_upload(file, temp_path)
command = [
"exiftool",
"-overwrite_original",
"-m",
f"-EXIF:DateTimeOriginal={exif_datetime}",
f"-EXIF:CreateDate={exif_datetime}",
f"-EXIF:ModifyDate={exif_datetime}",
f"-XMP-exif:DateTimeOriginal={exif_datetime}",
f"-XMP-xmp:CreateDate={exif_datetime}",
f"-XMP-xmp:ModifyDate={exif_datetime}",
f"-XMP-photoshop:DateCreated={xmp_date}",
f"-XMP-photoshop:Instructions=Photo Date Editor precision: {precision_label}",
"-EXIF:ImageDescription=",
"-XMP-dc:Description=",
"-IPTC:Caption-Abstract=",
"-XMP-dc:Subject=",
"-IPTC:Keywords=",
]
clean_description = description.strip()
if clean_description:
command.extend(
[
f"-EXIF:ImageDescription={clean_description}",
f"-XMP-dc:Description={clean_description}",
f"-IPTC:Caption-Abstract={clean_description}",
]
)
for keyword in keywords:
command.append(f"-XMP-dc:Subject+={keyword}")
command.append(f"-IPTC:Keywords+={keyword}")
command.append(str(temp_path))
logger.info("Processing %s with precision %s", filename, precision)
completed = subprocess.run(
command,
capture_output=True,
text=True,
timeout=90,
check=False,
)
if completed.returncode != 0:
logger.error("ExifTool failed: %s", completed.stderr.strip())
raise HTTPException(status_code=500, detail="ExifTool could not update this image.")
updated_bytes = temp_path.read_bytes()
return Response(
content=updated_bytes,
media_type="image/jpeg",
headers={
"Content-Disposition": f'inline; filename="{Path(filename).name}"',
"Cache-Control": "no-store",
"X-Date-Precision": precision_label,
},
)
@app.get("/service-worker.js", include_in_schema=False)
def service_worker() -> FileResponse:
return FileResponse(STATIC_DIR / "service-worker.js", media_type="application/javascript")
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")