Initial JPEG photo metadata editor v0.1
This commit is contained in:
1 parent
cdc206680c
commit
8738b8c814
12 files changed
+1222
No files matched your search
+217
@@ -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")
|
||||
Reference in new issue
Block a user