Add metadata display and skipped state v0.2
This commit is contained in:
1 parent
8738b8c814
commit
e2f5c91138
8 files changed
+322
-40
No files matched your search
+151
-3
@@ -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=",
|
||||
|
||||
Reference in new issue
Block a user