fix(metadata): drop EXIF:ModifyDate fallback, prefer SubSec, fall back to path

The taken_at extractor walked four EXIF fields in order: DateTimeOriginal,
CreateDate, MediaCreateDate, ModifyDate. The last one is set every time
a file is re-saved (Lightroom export, EXIF strip, batch resize), so any
photo whose original capture metadata was lost during editing ended up
labeled 'exif' with the *edit* date instead of the shoot date.

Changes:
  - SubSecDateTimeOriginal at the top of the list (sub-second precision,
    often carries OffsetTime).
  - QuickTime:CreateDate added next to MediaCreateDate.
  - ModifyDate dropped from the trusted list entirely.
  - When no trusted EXIF date is present, fall back to guess_date_from_path
    (already used for has_date_warning) and tag taken_at_source='path'.
    Better than filesystem mtime, which on Nextcloud-mounted libraries
    just reflects the upload time.
  - Skip the date-write block entirely if photo.taken_at_source == 'manual'
    so a rescan can't clobber a user correction.
  - parse_exif_datetime: handle the all-zero placeholder some cameras
    emit, accept tz-aware variants (%z), normalize to naive UTC.

Frontend: new 'PATH' badge in TakenAtEditor with a tooltip explaining
the date came from filename / folder rather than real EXIF.

Backfill: new backfill_taken_at celery task and
POST /api/v1/library/maintenance/backfill-taken-at endpoint that
re-enqueues extract_metadata for every non-manual photo. ~21k tasks
finish in ~15 min on the existing worker-light concurrency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claudio
2026-05-10 23:02:32 +02:00
parent 76551d898b
commit 7b153f0d28
3 changed files with 134 additions and 24 deletions

View File

@@ -187,6 +187,19 @@ async def trigger_backfill_gps(current_user: User = Depends(get_current_user)):
backfill_gps.delay()
return {"status": "success", "message": "GPS backfill queued"}
@router.post("/maintenance/backfill-taken-at")
async def trigger_backfill_taken_at(current_user: User = Depends(get_current_user)):
"""Re-run extract_metadata on every non-manual photo to recompute
taken_at with the current EXIF-priority list and path-based fallback.
Useful after the date-extraction logic changes (e.g. dropping the
ModifyDate fallback). Manual edits are preserved."""
from app.services.metadata import backfill_taken_at
backfill_taken_at.delay()
return {"status": "success", "message": "taken_at backfill queued"}
@router.get("/scan/status")
async def get_scan_status(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Get current scan status"""