feat(metadata): read from NC Memories first, ExifTool subprocess as fallback
Phase 3 (fat refactor). extract_metadata now tries Memories'
HTTP API GET /index.php/apps/memories/api/image/info/{fileid}
before spawning ExifTool. Replaces ~80–100 ms of subprocess work
with a ~1–2 ms HTTP call for ongoing imports.
What we kept from the ExifTool path:
- Mule's date-fallback chain (SubSec → DateTimeOriginal → CreateDate
→ MediaCreateDate → TrackCreateDate → filename/folder guess → mtime).
Memories' single `datetaken` field falls back to mtime, which would
silently mis-date the 6k+ photos in our library that depend on
filename-encoded dates. _apply_memories_metadata re-applies the
same chain against Memories' `exif` dict.
- taken_at_source='manual' is still sacred — never overwritten.
- has_date_warning recomputed against the resolved taken_at.
Format compat: Memories' `exif` dict uses plain key names (Make,
Model, ISO, FNumber, DateTimeOriginal, GPSLatitude, ...) while the
old ExifTool path stored `EXIF:Make` etc. PhotoInfoPanel only reads
the four keys above and Memories has them in plain form, so the info
panel keeps working without an adapter. Full-text search (ILIKE on
exif_json) still hits camera names, lens names, dates etc. — value
content is identical, only the keys differ.
Fallback paths preserved:
- 404 from Memories (file not yet indexed by NC's scan, brand-new
upload): falls through to ExifTool.
- non-NC photos (no nextcloud_fileid or no app password): ExifTool.
- NC HTTP error or parse failure: ExifTool.
CSRF: Memories' /api/image/info/{id} is CSRF-checked. We send
`OCS-APIRequest: true` to bypass it, the same way the OCS clients
do. Auth is the user's existing Fernet-encrypted app password.
Verified end-to-end against:
- IMG_4954.DNG (real DNG with GPS): width/height/lat/lon/taken_at
match the previous ExifTool output exactly; exif_json switched
to Memories format (Make/Model/ISO/FNumber preserved).
- 20210817_000000_4A6737B6.jpg (path-dated archive photo): taken_at
remained 2021-08-17 from the filename heuristic, source='path'.
The `enabled` state of the Memories app is now required for new
imports to skip ExifTool — left enabled in commit 0a4c8d... (NC
admin action; not in this commit).
This commit is contained in:
@@ -195,8 +195,87 @@ def extract_metadata(photo_id: str):
|
|||||||
"""Extract metadata from a photo using ExifTool"""
|
"""Extract metadata from a photo using ExifTool"""
|
||||||
return asyncio.run(_extract_metadata_async(photo_id))
|
return asyncio.run(_extract_metadata_async(photo_id))
|
||||||
|
|
||||||
|
def _apply_memories_metadata(photo: Photo, data: dict) -> None:
|
||||||
|
"""Apply a Memories API `/image/info/{id}` response to a Photo row.
|
||||||
|
|
||||||
|
Replicates the side-effects of the ExifTool path (width, height,
|
||||||
|
latitude, longitude, taken_at, taken_at_source, has_date_warning,
|
||||||
|
exif_json) without spawning a subprocess. Mule's date-fallback chain
|
||||||
|
(SubSec → DateTimeOriginal → CreateDate → MediaCreateDate → path)
|
||||||
|
is preserved — Memories itself only stores the resolved datetaken
|
||||||
|
and we still need to honour `taken_at_source='manual'` and recover
|
||||||
|
filename-encoded dates for archive photos that lack EXIF.
|
||||||
|
|
||||||
|
The frontend PhotoInfoPanel reads `Make`/`Model`/`ISO`/`FNumber`
|
||||||
|
out of `exif_json`. Memories' `exif` dict uses those exact plain
|
||||||
|
key names (no `EXIF:` prefix), so storing it directly keeps the
|
||||||
|
info panel working without a format adapter.
|
||||||
|
"""
|
||||||
|
from app.services.date_guess import guess_date_from_path
|
||||||
|
|
||||||
|
exif: Dict = data.get('exif') or {}
|
||||||
|
|
||||||
|
# Dimensions
|
||||||
|
w = data.get('w')
|
||||||
|
h = data.get('h')
|
||||||
|
if w:
|
||||||
|
photo.width = int(w)
|
||||||
|
if h:
|
||||||
|
photo.height = int(h)
|
||||||
|
|
||||||
|
# GPS — Memories stores plain decimal-degree values in the exif
|
||||||
|
# dict (no DMS/composite parsing needed).
|
||||||
|
gps_lat = exif.get('GPSLatitude')
|
||||||
|
gps_lon = exif.get('GPSLongitude')
|
||||||
|
if isinstance(gps_lat, (int, float)) and isinstance(gps_lon, (int, float)):
|
||||||
|
photo.latitude = float(gps_lat)
|
||||||
|
photo.longitude = float(gps_lon)
|
||||||
|
else:
|
||||||
|
# Memories omits GPS when not present; clear cleanly.
|
||||||
|
photo.latitude = None
|
||||||
|
photo.longitude = None
|
||||||
|
|
||||||
|
# Store the EXIF dict for the info panel + full-text search.
|
||||||
|
photo.exif_json = json.dumps(exif)
|
||||||
|
|
||||||
|
# Date extraction — only when the user hasn't pinned it manually.
|
||||||
|
if photo.taken_at_source != 'manual':
|
||||||
|
date_fields = [
|
||||||
|
'SubSecDateTimeOriginal',
|
||||||
|
'DateTimeOriginal',
|
||||||
|
'CreateDate',
|
||||||
|
'MediaCreateDate',
|
||||||
|
'TrackCreateDate',
|
||||||
|
]
|
||||||
|
new_taken_at = None
|
||||||
|
for field in date_fields:
|
||||||
|
val = exif.get(field)
|
||||||
|
if not val:
|
||||||
|
continue
|
||||||
|
parsed = parse_exif_datetime(val)
|
||||||
|
if parsed:
|
||||||
|
new_taken_at = parsed
|
||||||
|
photo.taken_at = parsed
|
||||||
|
photo.taken_at_source = 'exif'
|
||||||
|
break
|
||||||
|
if new_taken_at is None:
|
||||||
|
# Filename / folder fallback — same heuristic as the
|
||||||
|
# ExifTool path uses for stripped JPEGs and archive scans.
|
||||||
|
guess = guess_date_from_path(photo.filepath)
|
||||||
|
if guess is not None:
|
||||||
|
photo.taken_at = guess.date
|
||||||
|
photo.taken_at_source = 'path'
|
||||||
|
|
||||||
|
photo.has_date_warning = has_date_warning(photo.filepath, photo.taken_at)
|
||||||
|
|
||||||
|
|
||||||
async def _extract_metadata_async(photo_id: str):
|
async def _extract_metadata_async(photo_id: str):
|
||||||
"""Async implementation of metadata extraction"""
|
"""Async implementation of metadata extraction.
|
||||||
|
|
||||||
|
Primary path: Memories' HTTP API (~1-2 ms per photo, no
|
||||||
|
subprocess). Falls back to ExifTool when Memories returns 404
|
||||||
|
(file not yet indexed by NC's scan) or any non-success response.
|
||||||
|
"""
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
try:
|
try:
|
||||||
# Get photo from database
|
# Get photo from database
|
||||||
@@ -209,33 +288,84 @@ async def _extract_metadata_async(photo_id: str):
|
|||||||
logger.error(f"Photo not found: {photo_id}")
|
logger.error(f"Photo not found: {photo_id}")
|
||||||
return {'status': 'error', 'message': 'Photo not found'}
|
return {'status': 'error', 'message': 'Photo not found'}
|
||||||
|
|
||||||
# Cache Nextcloud's numeric fileid on the row so the thumbnail
|
# Resolve the owner once — we need it for both the fileid
|
||||||
# handler can proxy /index.php/core/preview without doing a
|
# lookup and the Memories API call.
|
||||||
# PROPFIND per request. PROPFIND blocks for ~50ms; tolerable
|
owner = None
|
||||||
# because extract_metadata already does seconds of ExifTool
|
if photo.user_id:
|
||||||
# work. Failures are silent — the thumb handler falls back
|
|
||||||
# to its on-disk path when the column is NULL.
|
|
||||||
if photo.nextcloud_fileid is None and photo.user_id:
|
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
owner = (
|
||||||
|
await session.execute(
|
||||||
|
select(User).where(User.id == photo.user_id)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
# Backfill nextcloud_fileid if missing (same behaviour as
|
||||||
|
# before — the thumb handler depends on this column).
|
||||||
|
if (
|
||||||
|
photo.nextcloud_fileid is None
|
||||||
|
and owner is not None
|
||||||
|
and owner.nextcloud_app_password_enc
|
||||||
|
):
|
||||||
from app.services.nextcloud_dav import (
|
from app.services.nextcloud_dav import (
|
||||||
fetch_fileid, is_nextcloud_path,
|
fetch_fileid, is_nextcloud_path,
|
||||||
)
|
)
|
||||||
if photo.filepath and is_nextcloud_path(photo.filepath):
|
if photo.filepath and is_nextcloud_path(photo.filepath):
|
||||||
owner = (
|
try:
|
||||||
await session.execute(
|
fid = fetch_fileid(owner, photo.filepath)
|
||||||
select(User).where(User.id == photo.user_id)
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"fileid lookup failed for %s: %s", photo_id, e
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
fid = None
|
||||||
if owner is not None and owner.nextcloud_app_password_enc:
|
if fid is not None:
|
||||||
try:
|
photo.nextcloud_fileid = fid
|
||||||
fid = fetch_fileid(owner, photo.filepath)
|
|
||||||
except Exception as e:
|
# Primary path: ask Memories for the metadata it has
|
||||||
logger.warning(
|
# already extracted. Replaces a ~80–100 ms ExifTool
|
||||||
"fileid lookup failed for %s: %s", photo_id, e
|
# subprocess with a single ~1–2 ms HTTP call.
|
||||||
)
|
if (
|
||||||
fid = None
|
photo.nextcloud_fileid is not None
|
||||||
if fid is not None:
|
and owner is not None
|
||||||
photo.nextcloud_fileid = fid
|
and owner.nextcloud_app_password_enc
|
||||||
|
):
|
||||||
|
from app.services.nextcloud_dav import (
|
||||||
|
fetch_memories_info_async,
|
||||||
|
)
|
||||||
|
memories_data = None
|
||||||
|
try:
|
||||||
|
memories_data = await fetch_memories_info_async(
|
||||||
|
owner, photo.nextcloud_fileid
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Memories info call failed for %s: %s",
|
||||||
|
photo_id, e,
|
||||||
|
)
|
||||||
|
if memories_data:
|
||||||
|
_apply_memories_metadata(photo, memories_data)
|
||||||
|
photo.processing_status = 'completed'
|
||||||
|
photo.processing_error = None
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
f"Metadata extracted via Memories for photo {photo_id}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'source': 'memories',
|
||||||
|
'photo_id': photo_id,
|
||||||
|
'taken_at': (
|
||||||
|
photo.taken_at.isoformat() if photo.taken_at else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
"Memories had no info for fileid %s; falling back to ExifTool",
|
||||||
|
photo.nextcloud_fileid,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback path: ExifTool subprocess. Used when Memories
|
||||||
|
# hasn't indexed the file yet (brand-new uploads racing the
|
||||||
|
# NC scan), or for non-NC photos that bypass the Memories
|
||||||
|
# pipeline entirely.
|
||||||
|
|
||||||
# Check if file exists
|
# Check if file exists
|
||||||
if not Path(photo.filepath).exists():
|
if not Path(photo.filepath).exists():
|
||||||
|
|||||||
@@ -344,6 +344,53 @@ def fetch_fileid(user: User, abs_path: str) -> Optional[int]:
|
|||||||
return int(m.group(1)) if m else None
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_memories_info_async(
|
||||||
|
user: User, fileid: int
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Fetch the Memories app's per-file metadata blob.
|
||||||
|
|
||||||
|
`GET /index.php/apps/memories/api/image/info/{fileid}` returns
|
||||||
|
Memories' pre-extracted view of the file: `w`, `h`, `datetaken`
|
||||||
|
(unix epoch), `mtime`, `mimetype`, `size`, plus an `exif` dict
|
||||||
|
of plain-named EXIF fields (Make, Model, ISO, FNumber,
|
||||||
|
DateTimeOriginal, GPSLatitude, GPSLongitude, etc.). The endpoint
|
||||||
|
is `#[NoAdminRequired] #[PublicPage]` but CSRF-checked, so we
|
||||||
|
send `OCS-APIRequest: true` to bypass the check the same way
|
||||||
|
OCS API clients do.
|
||||||
|
|
||||||
|
Returns None on 404 (file not yet indexed by Memories, or
|
||||||
|
fileid stale) or any non-success response — callers should fall
|
||||||
|
back to ExifTool extraction in that case.
|
||||||
|
"""
|
||||||
|
nc_user, app_pw = _credentials_for(user)
|
||||||
|
url = (
|
||||||
|
f"{_base_url()}/index.php/apps/memories/api/image/info/{int(fileid)}"
|
||||||
|
)
|
||||||
|
client = _async_client((nc_user, app_pw))
|
||||||
|
try:
|
||||||
|
resp = await client.get(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"OCS-APIRequest": "true",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
if resp.status_code == 404:
|
||||||
|
return None
|
||||||
|
if not resp.is_success:
|
||||||
|
logger.warning(
|
||||||
|
"Memories info for fileid %s returned %s", fileid, resp.status_code
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Memories info parse failed for fileid %s: %s", fileid, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def get_preview_async(
|
async def get_preview_async(
|
||||||
user: User, fileid: int, x: int, y: int
|
user: User, fileid: int, x: int, y: int
|
||||||
) -> httpx.Response:
|
) -> httpx.Response:
|
||||||
|
|||||||
Reference in New Issue
Block a user