fix(images): fence the capture-date backfill on the file it read (#1201) (#1205)

Stable twin of #1204.

The capture-date backfill committed its result keyed on the row id alone. It
snapshots every candidate up front, then walks them one at a time reading
originals off S3 or a NAS mount — a pass that can run for many minutes.

replacePhoto, reachable from the replace_by_name upload path, swaps a NEW file
under an existing row and rewrites path/filename. A replacement landing inside
that window carries no date of its own, so captured_at was still NULL, the
whereNull guard passed, and the previous file's EXIF date was written onto the
new photo. Silent: nothing errored, the run reported it as a success, and the
gallery just sorted that photo to the wrong place.

Fenced on path and filename as well as the id, so a replaced row matches zero
rows and is skipped. The candidate query already selects both columns, so no
query change. Knex renders a null value in the object form as `is null` on both
the pg and sqlite3 clients, so a row with a NULL path still matches itself.

Those skipped candidates are now counted rather than dropped. replacePhoto is
not the only writer of path/filename — eventRenameService rewrites both on an
event rename, which is not a content change — and another writer filling
captured_at first lands in the same place. Without a counter they fell out of
the run's arithmetic entirely: success + noExif + failed no longer added up to
the count the operator was shown when they started the job.

The card shows the count only when it is non-zero, and states what is known —
changed by something else, not updated — rather than promising a retry: for the
already-dated case there is nothing to retry, and the Missing Capture Date
figure above is what says whether work is left. Locale coverage: en, de, fr, sl,
with the defaultValue carrying the rest.

Regression test: a replacement landing mid-run leaves captured_at NULL and is
not counted as updated. Verified to fail against the unfenced code on this
branch.
This commit is contained in:
Paul Nothaft
2026-08-27 08:44:32 +02:00
committed by GitHub
parent 292dd4fa09
commit 74ff236b51
7 changed files with 73 additions and 5 deletions
@@ -689,6 +689,23 @@ export const StatusTab: React.FC<StatusTabProps> = ({
failed: captureDateStatus.lastResult.failed,
defaultValue: 'Last run: {{success}} updated, {{noExif}} with no date found, {{failed}} unreachable',
})}
{/* Only when it happened. Without it the three numbers above
silently stop adding up to the count the run started with: a
photo that was replaced, renamed or dated by someone else
mid-run is read but not written.
Deliberately says "not updated" and not "will be retried":
one of the two ways to land here is another writer having
filled captured_at, and that photo is finished, not backlog.
The Missing Capture Date figure above is what says whether
anything is actually left to do. */}
{Number(captureDateStatus.lastResult.skipped) > 0 && (
<span className="block text-amber-600 dark:text-amber-400 mt-1">
{t('settings.captureDates.skipped', {
count: captureDateStatus.lastResult.skipped,
defaultValue: '{{count}} photo(s) were changed by something else while the run was reading them and were not updated.',
})}
</span>
)}
</p>
)}
+1
View File
@@ -2077,6 +2077,7 @@
"running": "Wird nachgetragen...",
"noneToFill": "Alle Fotos haben bereits ein Aufnahmedatum",
"resultSuccess": "Letzter Lauf: {{success}} aktualisiert, {{noExif}} ohne gefundenes Datum, {{failed}} nicht erreichbar",
"skipped": "{{count}} Foto(s) wurden während des Laufs anderweitig geändert und daher nicht aktualisiert.",
"description": "Trägt „Aufnahmedatum\" aus den EXIF-Daten nach, für Fotos die vor dieser Auswertung importiert wurden. Externe Importe haben nie eines gespeichert, dadurch sortieren diese Galerien nach Importreihenfolge statt nach Aufnahmezeit."
}
},
+1
View File
@@ -1618,6 +1618,7 @@
"running": "Backfilling...",
"noneToFill": "All photos already have a capture date",
"resultSuccess": "Last run: {{success}} updated, {{noExif}} with no date found, {{failed}} unreachable",
"skipped": "{{count}} photo(s) were changed by something else while the run was reading them and were not updated.",
"description": "Backfill \"Date Taken\" from EXIF for photos imported before capture dates were read. External/reference imports never recorded one, so their galleries sort by import order instead of when the photos were taken."
}
},
+1
View File
@@ -1181,6 +1181,7 @@
"running": "Traitement...",
"noneToFill": "Toutes les photos ont déjà une date de prise de vue",
"resultSuccess": "Dernier passage : {{success}} mises à jour, {{noExif}} sans date trouvée, {{failed}} inaccessibles",
"skipped": "{{count}} photo(s) ont été modifiées par autre chose pendant le passage et n'ont donc pas été mises à jour.",
"description": "Complète la « date de prise de vue » depuis les EXIF pour les photos importées avant sa lecture. Les imports externes n'en enregistraient aucune, si bien que ces galeries se trient par ordre d'import plutôt que par date de prise de vue."
}
},
+1
View File
@@ -1181,6 +1181,7 @@
"running": "Dopolnjevanje...",
"noneToFill": "Vse fotografije že imajo datum zajema",
"resultSuccess": "Zadnji zagon: {{success}} posodobljenih, {{noExif}} brez najdenega datuma, {{failed}} nedosegljivih",
"skipped": "{{count}} fotografij je bilo med zagonom spremenjenih drugje in zato niso bile posodobljene.",
"description": "Dopolni »datum zajema« iz EXIF za fotografije, uvožene pred njegovim branjem. Zunanji uvozi ga niso zabeležili, zato se te galerije razvrščajo po vrstnem redu uvoza namesto po času zajema."
}
},