Add metadata display and skipped state v0.2

This commit is contained in:
dmo committed 2026-07-23 01:12:10 +02:00
1 parent 8738b8c814
commit e2f5c91138
8 files changed
+322 -40

No files matched your search

+151 -3
View File
@@ -3,11 +3,12 @@ from __future__ import annotations
import json
import logging
import os
import re
import subprocess
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Annotated
from typing import Annotated, Any
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse, Response
@@ -15,6 +16,7 @@ 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.2.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()
@@ -33,7 +35,7 @@ app = FastAPI(title=APP_NAME, docs_url=None, redoc_url=None)
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
return {"status": "ok", "version": APP_VERSION}
@app.get("/api/config")
@@ -41,6 +43,7 @@ def config() -> dict[str, str | int]:
return {
"appName": APP_NAME,
"appUrl": APP_URL,
"version": APP_VERSION,
"maxUploadMb": MAX_UPLOAD_MB,
}
@@ -113,6 +116,150 @@ async def _save_upload(upload: UploadFile, destination: Path) -> None:
raise HTTPException(status_code=415, detail="The uploaded file is not a valid JPEG image.")
def _first_value(metadata: dict[str, Any], *keys: str) -> Any:
for key in keys:
value = metadata.get(key)
if value not in (None, "", []):
return value
return None
def _as_text(value: Any) -> str:
if isinstance(value, list):
return str(value[0]).strip() if value else ""
if isinstance(value, dict):
for candidate in ("x-default", "en", "en-US"):
if candidate in value:
return str(value[candidate]).strip()
return str(next(iter(value.values()), "")).strip()
return str(value or "").strip()
def _as_keywords(*values: Any) -> list[str]:
output: list[str] = []
seen: set[str] = set()
for value in values:
items = value if isinstance(value, list) else [value]
for item in items:
text = _as_text(item)
if not text or text.casefold() in seen:
continue
seen.add(text.casefold())
output.append(text)
return output
def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, str]]:
date_text = _as_text(
_first_value(
metadata,
"DateTimeOriginal",
"CreateDate",
"DateCreated",
"ModifyDate",
)
)
instructions = _as_text(_first_value(metadata, "Instructions"))
description = _as_text(
_first_value(
metadata,
"ImageDescription",
"Description",
"Caption-Abstract",
)
)
keywords = _as_keywords(metadata.get("Subject"), metadata.get("Keywords"))
precision = "exact"
time_mode = "known"
marker = re.search(
r"Photo Date Editor precision:\s*(Exact date|Month and year|Year only|Approximate year)"
r"(?:;\s*time:\s*(Known|Unknown))?",
instructions,
flags=re.IGNORECASE,
)
if marker:
precision = {
"exact date": "exact",
"month and year": "month",
"year only": "year",
"approximate year": "approximate",
}[marker.group(1).casefold()]
if marker.group(2):
time_mode = "known" if marker.group(2).casefold() == "known" else "unknown"
parsed_date: datetime | None = None
for pattern in ("%Y:%m:%d %H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y:%m:%d", "%Y-%m-%d"):
try:
parsed_date = datetime.strptime(date_text, pattern)
break
except ValueError:
continue
if marker and not marker.group(2) and parsed_date and parsed_date.strftime("%H:%M:%S") == "12:00:00":
# Backward compatibility with v0.1, which used noon for unknown time but did not store a marker.
time_mode = "unknown"
values = {
"precision": precision,
"year": str(parsed_date.year) if parsed_date else "",
"month": str(parsed_date.month) if parsed_date else "1",
"day": str(parsed_date.day) if parsed_date else "1",
"timeMode": time_mode,
"time": parsed_date.strftime("%H:%M:%S") if parsed_date else "12:00:00",
"description": description,
"keywords": ", ".join(keywords),
}
has_metadata = bool(parsed_date or description or keywords or marker)
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]]:
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.")
with tempfile.TemporaryDirectory(prefix="photo-date-editor-read-") as temp_dir:
temp_path = Path(temp_dir) / f"source{suffix}"
await _save_upload(file, temp_path)
completed = subprocess.run(
[
"exiftool",
"-json",
"-s",
"-DateTimeOriginal",
"-CreateDate",
"-ModifyDate",
"-DateCreated",
"-Instructions",
"-ImageDescription",
"-Description",
"-Caption-Abstract",
"-Subject",
"-Keywords",
str(temp_path),
],
capture_output=True,
text=True,
timeout=45,
check=False,
)
if completed.returncode != 0:
logger.error("ExifTool metadata read failed: %s", completed.stderr.strip())
raise HTTPException(status_code=500, detail="ExifTool could not read this image's metadata.")
try:
output = json.loads(completed.stdout)
metadata = output[0] if isinstance(output, list) and output else {}
except json.JSONDecodeError as exc:
raise HTTPException(status_code=500, detail="ExifTool returned invalid metadata output.") from exc
has_metadata, edited_by_app, values = _metadata_values(metadata)
return {"hasMetadata": has_metadata, "editedByApp": edited_by_app, "values": values}
@app.post("/api/process")
async def process_photo(
file: Annotated[UploadFile, File(...)],
@@ -136,6 +283,7 @@ async def process_photo(
day=day,
time_value=time_value,
)
time_label = "Known" if time_value else "Unknown"
try:
raw_keywords = json.loads(keywords_json)
@@ -160,7 +308,7 @@ async def process_photo(
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}",
f"-XMP-photoshop:Instructions=Photo Date Editor precision: {precision_label}; time: {time_label}",
"-EXIF:ImageDescription=",
"-XMP-dc:Description=",
"-IPTC:Caption-Abstract=",