Skip to content

Library sync removes missing images - #70

Draft
gosku wants to merge 36 commits into
mainfrom
library-sync-removes-missing-images
Draft

Library sync removes missing images#70
gosku wants to merge 36 commits into
mainfrom
library-sync-removes-missing-images

Conversation

@gosku

@gosku gosku commented Aug 9, 2026

Copy link
Copy Markdown
Owner

No description provided.

gosku and others added 30 commits August 8, 2026 21:34
Schema only: nothing writes these yet. A run will need somewhere to record
how many images it removed, how many it found missing, which prune the
caller asked for, and why a prune removed nothing.

prune_mode is persisted rather than passed around because the caller that
finalises a run may be a different process entirely, so command-line flags
cannot reach it any other way.

PRUNING joins ACTIVE_STATES so that, once pruning exists, the
unique-active-run constraint keeps a second sync from starting while a
prune walks the tree and re-importing files it is about to remove. The
constraint is rebuilt because its condition embeds the state tuple.

failure_reason lets a caller tell "folder is missing on disk" apart from
any other failure, which today is guessed from the state alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mark_completed baked one specific transition, and its legality, into the
model. Pruning needs a second guarded transition, and copying the pattern
would put more policy there.

transition_state does the guarded write and nothing else: it reports how
many rows it touched and leaves the caller to decide which transitions are
legal and whether the target state is terminal. The WHERE clause has to
stay in SQL, since moving it into Python would turn the exactly-one-winner
election into a read-then-write race.

complete_sync_run moves onto it in the same commit, because the mutator it
called no longer exists. It also now accepts a run that is pruning, which
is the transition the next commits need.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bound how much an automatic prune may remove in one pass, so an unmounted
drive or an unreadable directory is reported rather than applied. Both
thresholds must be exceeded for the guard to engage, which keeps ordinary
small cleanups from tripping it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The width lived as a constant inside the generate_thumbnails command, so
nothing else could know which widths exist. Removing an image has to clear
its cached thumbnail at every width, which needs a definitive list.

Overridable from the env file as a comma-separated list
(THUMBNAIL_WIDTHS=600,1200); the command now generates each configured
width rather than a single hardcoded one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Thumbnail cache keys are derived from the image path, so removing an image
without clearing its cache leaves files that a later image reusing that
path would be served instead of its own. Fujifilm filenames wrap around
from DSCF9999 to DSCF0001, which makes that a real possibility rather than
a theoretical one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing a folder from the Library can take its images with it, but
library folders may nest: an image below /photos/2024 is also below
/photos. These queries scope removal to images no other registered folder
covers, so removing the inner folder never takes images the outer one
still monitors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing calls it yet. A catalog record will need to follow its file when
the file is renamed or moved, so that the record survives instead of being
stranded on a path that no longer exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An import that matches an existing record by content hash is either a move
or a copy, and the old file tells them apart: gone means the bytes were
renamed or moved, still there means this is a second copy. Only a move
repoints the record.

Until now the filepath was never updated, so a renamed or moved photo left
its record pointing at a path that no longer exists: the gallery kept
showing it and every thumbnail request 404'd. Repointing also means a
rename keeps the photo's rating, favourite mark and album membership,
which a delete-and-re-import would throw away.

Wired into process_image here, since a record that never followed its file
is exactly the bug being fixed. One dedup test asserted the old behaviour
and now asserts relocation.

Never touches either file on disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing calls it yet. Removes a catalog entry and garbage-collects the
FujifilmExif row it orphans, mirroring the tail of merge_image_into.

The image file on disk is never touched; only the derived thumbnail cache
is cleared, and that is for correctness rather than tidiness: cache keys
come from the path, and Fujifilm filenames wrap around from DSCF9999 to
DSCF0001, so a later file reusing a path would be served the previous
image's thumbnail.

The recipe is never deleted, since recipes are shared and outlive their
images. Cover-image and recipe-card references are left to the schema's
SET_NULL: a recipe with no explicit cover already falls back to its
most-used image, and a card is a rendered JPEG that stands on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scan skipped directories whose mtime predated the folder's
last_checked_at. That cannot survive alongside removal: renaming a
directory updates its parent's mtime and never its own, so a renamed
subtree keeps an old timestamp, last_checked_at only moves forward, and a
gated walk never looks at it again. Every image under a renamed folder
would be treated as deleted and never found.

The gate bought little anyway. os.walk has already listed each directory
by the time the check runs, so it added a getmtime() per directory and
saved only a filename suffix check; the costly work is already avoided by
diffing against the known catalog paths.

last_checked_at stays, since the Library page shows it and it is the only
evidence a sync ran when nothing changed. Only its gating role goes, which
also makes clearing it on a path update pointless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing calls it yet. Removal has to be scoped to one library folder, and
Image has no foreign key to LibraryFolder, so membership can only be
decided by path prefix. The separator is part of the prefix so that a
folder named /photos does not also claim /photos-old.

Scoping this way is what will keep images imported from outside the
library, by the process_images command, out of any removal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing calls it yet. Removes catalog entries for images under one library
folder whose files are gone, and nothing else: no image file is ever
deleted from disk.

Candidates come from the difference between the catalogued paths and a
fresh walk, then each is confirmed with a stat. The walk alone would be
wrong, because it does not follow symlinked directories, silently yields
nothing for a directory it cannot read, and matches only JPEG extensions,
so anything it cannot see would look deleted. Removal is permanent, so the
filesystem gets the final say. lexists rather than exists, because a
broken symlink still occupies the path and the conservative reading has to
win.

prune_guard_trips stops a pass that would take most of a folder at once,
which is far more often an unmounted drive or an unreadable directory than
a real deletion. Both thresholds must be exceeded, so ordinary cleanups
are applied without a warning.

Nothing is pruned when the folder itself is absent, since an unplugged
drive must not empty the gallery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing calls it yet. Moves a run from processing into a prune phase,
electing a single winner under concurrent workers so the prune runs once
however many of them arrive together.

Electing here rather than after completion is deliberate. Pruning after a
run is already complete would stop the progress poller before the counts
are written, let a second sync start while the tree is still being walked,
and leave a run claiming COMPLETED if the process dies mid-prune. A
distinct active state avoids all three, and the existing startup recovery
already handles it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sync_library inferred "this folder is missing from disk" from the run
state alone, so any failure whatsoever was reported to the user as a
missing folder. The free-text message that held the real reason was never
surfaced anywhere.

fail_sync_run now takes a code alongside the message, and its one caller
passes it. The distinction matters more once removal exists: a missing
folder is usually an unplugged drive, and it is the moment a user most
needs telling that nothing was taken out of their gallery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing calls it yet. Elects a single finaliser for a run, prunes the
folder, then completes the run whatever the prune did.

Pruning belongs at the end of a run rather than inside the scan, because
imports have to land first: a file that moved has only repointed its
record once it has been re-imported, and until then it looks deleted.
Putting the orchestration in a use case keeps Celery out of the domain,
and gives every possible last caller, the scan that found nothing and each
per-image task, one function to reach.

It also defers when another folder is still syncing, which is what stops a
file moved between two library folders being removed by the source before
the destination has picked it up. Delaying a removal is always safe;
removing early is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing calls it yet. Removes catalog entries for one folder's missing
files without scanning for new ones, and records the outcome on that
folder's latest run so the Library page reflects it.

Lite mode needs this: it scans folders one after another, so pruning
inside each sync would remove a file moved into a folder that has not been
looked at yet. Importing everything first and then pruning needs a way to
prune on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing a folder used to leave every image it had imported behind, with
nothing recording where they came from.

The choice is a required argument rather than a defaulted one, because
which of the two happens is the whole point and no caller should get it by
accident. That makes this one commit across three layers: the domain
signature change forces the use case and the view with it.

Only images no other registered folder covers are taken, so removing a
folder nested inside another never touches images the outer one still
monitors. The photo files themselves stay on disk.

The view reads the choice from the form; the confirmation that offers it
comes next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This is where removal starts happening. The pieces existed but nothing
called them.

Both places that could be the last one standing now finalise through the
same use case: the scan when it found nothing new, which is exactly the
"the user deleted photos" case, and each per-image task as it finishes.
Imports therefore always land before anything is removed, so a file that
moved has already repointed its record and no longer looks missing.

Lite mode scans folders one after another, so a prune inside each sync
would run before later folders had been looked at, and a photo moved from
the first folder to the last would be removed moments before being
re-imported, losing its rating. It imports everything first, then prunes.
Full mode starts every run back to back, so the deferral rule already
covers the same case there.

The scenario tests are the acceptance suite for all of this: one test per
row of the matrix, building real trees of JPEGs, mutating them exactly as
each scenario describes and syncing again. Moves assert the record kept
its id, rating and favourite rather than merely still existing, and every
scenario asserts the photo files are still on disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing a folder can now take its images out of the gallery, so the user
has to be able to choose, and to see what the choice costs before making
it. A browser confirm() cannot show a count.

The dialog names how many images come only from this folder, which is the
number that would actually go, and says plainly that the photo files stay
on disk. When nothing would leave the gallery it offers only the plain
removal. It reuses the modal shell the folder browser already has rather
than adding a second one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removal has been happening automatically with no way to look before
leaping, no way to get past the safety guard, and no way to turn it off.

--dry-run-prune lists what would go and removes nothing, which is the flag
to reach for the first time the guard fires and you want to know what it
caught. --force-prune overrides the guard once you have looked.
--no-prune imports only.

The guard warning now names the folder and the counts and points at the
remedy, rather than leaving the user to guess why images they deleted are
still in the gallery. A missing folder says outright that nothing was
removed. PruneWarning carries the sampled paths the dry run prints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Sync column knew nothing about removal: a prune the guard stopped was
invisible, and a folder missing from disk rendered a bare "Sync failed"
with no reason, which is the single most alarming thing the page can show
and the least informative.

It now reports how many images a run removed, shows the prune phase while
it runs, and carries both warnings. The missing-folder one says "Folder
not found on disk. Nothing was removed from the gallery", because an
unplugged drive looks exactly like a mass deletion and that is the moment
the reassurance is worth most.

The view resolves the stored codes into booleans so templates never
compare against database values. Warnings are styled as warnings rather
than errors: nothing is broken and nothing was lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADR 013 records the decisions and carries the scenario matrix as the
specification of what "consistent with the folders" means,
cross-referenced to the test module that covers each numbered row.

ADRs 010 and 011 are left untouched: they record decisions that were taken
at the time, and a later decision does not get to rewrite them. The
supersession is stated in ADR 013 and in the docs index, following the
same direction ADR 012 used for ADR 005.

The user-facing docs lead with the thing that matters most: Filmcase never
deletes a photo file, and "removing" an image only removes its catalog
entry. The gating section is gone, since gating is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing writes it yet. A file the sync cannot import leaves no trace at
all today: process_image raises before any write, the invalid-recipe path
is rolled back by its own atomic block, and errors persist nothing. The
outcome survives only as a counter on the run and a log line.

That is why such a file is rediscovered on every single sync. On a 40k
library with 14.5k non-Fujifilm JPEGs, each startup re-runs exiftool on
every one of them to reach the same conclusion.

file_size and file_modified_at are the change fingerprint. A file whose
fingerprint still matches cannot have become importable, so a later sync
can pass over it for the cost of one stat rather than one process. A file
the user fixes in place changes its fingerprint and is examined again on
its own.

The foreign key scopes records to a folder for display, and takes them
with the folder when it is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing reads it yet. Sync currently publishes one broker message per new
file, synchronously, before the command returns, which is what makes a
large import block startup for tens of seconds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing calls them yet. Four reads the feature needs, each shaped by how
many rows it has to cope with: a 40k library can carry tens of thousands of
ignored files.

get_ignored_fingerprints returns one mapping in one query, because the sync
compares it against every candidate path it found on disk.
get_ignored_images returns a queryset rather than a list so the caller can
paginate rather than materialise the lot. get_ignored_counts_by_folder is a
single aggregate, so showing a count on every Library row does not become a
query per folder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing calls them yet.

record_ignored_image captures the file's size and modification time at the
moment it was examined. Re-recording an existing entry replaces that
fingerprint, which is the part that is easy to get wrong: a file that
changed, was examined again and failed again would otherwise keep its stale
fingerprint and be re-examined on every sync from then on.

forget_ignored_image and forget_ignored_images undo it, singly or in bulk
by reason, so a batch that failed for an environmental reason can be
reconsidered without touching the permanently unimportable ones.

None of this touches a file or moves an image in or out of the gallery. An
ignored file was never imported; forgetting its record only means the next
sync looks at it again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing reads these records yet, so behaviour is unchanged.

Every non-success branch now remembers the file alongside the counter it
already bumped. The error branch keeps the exception type and message,
truncated, which is what turns a run reporting "93 errors" into 93 files
you can actually look at: until now that detail existed only in a log line.

Success is the other half. A file that failed before and imports now must
stop being ignored, or the record outlives the truth and shows a photo
that is in the gallery as though it had been rejected.

A file that disappeared between the scan and processing cannot be stat'ed.
That is an ordinary race rather than a failure worth aborting the run for,
so it is logged and skipped: the next sync will not find it either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This is the fix. A file the sync cannot import has no catalog entry, so
the path diff counted it as new every single time and handed it to the
worker to be read from scratch again. On a 40k library with 14.5k
non-Fujifilm JPEGs that is 14.5k exiftool processes per startup, forever,
every one of them reaching the conclusion already reached.

Measured before this commit, on a folder holding one such file:

    run1: total=1 skipped=1
    run2: total=1 skipped=1
    run3: total=1 skipped=1

Candidates that already carry a record are now checked against the size
and modification time captured when they were last examined, and dropped
if neither has moved. The stat costs one syscall and is only paid for
files that are already ignored, so the extra work is bounded by how many
have failed rather than by the size of the tree.

Anything whose fingerprint has moved falls through and is examined again,
which is how a file the user fixes in place comes back on its own without
needing to be told.

This is a regression introduced earlier on this branch. Retiring mtime
gating was right, since it made renamed folders undetectable, but it
turned "retry a failed file when its directory changes" into "retry it
always". The tests here pin the inverse of the measurements above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing calls it yet. Sync dispatches one message per file, synchronously,
before the command can return, so the per-message bookkeeping sits
directly on the critical path of startup: 14.5k files means resolving the
same dotted path 14.5k times and running the structlog processor chain
14.5k times for a log record that is dropped at the handler anyway.

Resolving once and publishing one event carrying the count removes both.
Deliberately stopping there: sharing a broker producer and skipping the
result-backend call would shave more off each publish, but the next commit
cuts the message count by two orders of magnitude, after which the
per-message cost stops being worth machinery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dispatch is synchronous: the command that starts a sync cannot return
until the last message is published, and make start runs it to completion
before the server binds. One message per file therefore puts the whole
publish loop on the critical path of startup, which on a large import is
tens of seconds of an unreachable server.

Batching cuts the message count by the batch size, so 14.5k files become
~146 messages. Each image is still handled individually inside the batch,
so progress, ignore records and run completion are unchanged; only the
number of broker round trips falls.

The task is renamed rather than reused, because its arguments changed.
That means restarting the worker once after deploying, the same
consequence ADR 011 recorded for adding a task. Messages already queued
under the old name would be rejected as unknown; their run is recovered as
interrupted on the next start and re-imported.

Lite mode still processes inline, unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gosku and others added 6 commits August 9, 2026 21:20
Nothing calls them yet. Forgetting a record is how a file gets another
chance, so it needs to be reachable from both a button and a flag.

Limiting a bulk retry to one reason is the case that matters: a batch that
failed for an environmental reason deserves another look, while files that
are simply not Fujifilm would only be re-read to reach the same verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ignored files are now left alone until they change, which is right for the
common case and wrong when the reason they failed was environmental and
has since been put right. This is the escape hatch: forget everything and
look again.

It warns in its own help text that this is slow on a library carrying many
ignored files, because it is: examining them again is exactly the cost the
records exist to avoid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records were being kept but there was nowhere to see them, so 14.5k
skipped files and 93 errors existed only as counters that stopped being
reported once the files stopped being re-examined.

Paginated and filterable by reason, because the two groups want different
attention: the non-Fujifilm ones are noise, and the handful of errors are
the ones worth reading. Without the filter the errors are buried under
thousands of rows and effectively unfindable. Each error shows the message
that caused it, which until now lived only in a log line.

The page leads by saying no file was deleted or changed, since a list of
thousands of "ignored" photos invites exactly that fear.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The records were readable but permanent, so a file rejected for a reason
that has since been fixed had no way back short of a command-line flag
that forgets everything.

Three ways in, matching how the two groups differ. Per-row for the one
file you looked at and disagree with. "Retry all errors" for the common
case, a batch that failed environmentally, without dragging thousands of
non-Fujifilm files along. "Retry everything" behind a confirmation that
says the next sync will be slow, because it will.

A rejected non-Fujifilm file that has not changed will be rejected again
the moment it is examined, so its Retry says as much rather than appearing
broken when nothing happens. Error rows carry no such note, because there
retrying is exactly the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Without this the page exists but nothing points at it, and the files it
lists are invisible: once they stop being re-examined they also stop
appearing in the sync summary, so a user would have no way of knowing the
app had seen 14.5k files and decided against them.

One aggregate for the whole table rather than a count per row, since a
Library page should not cost a query per folder. Zero renders as plain
text, because a link to an empty page is just a dead end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADR 014 records why a file that cannot be imported is now remembered, why
the record is keyed to the file's size and modification time rather than
being a permanent verdict on a path, and why dispatch is batched. It also
revises ADR 013's risk 7, which described the cost as "two full walks per
sync" and understated it: the real cost was an exiftool process per
previously-failed file, on every sync.

ADR 013 itself is left untouched, as a record of what was decided then.

The user-facing docs lead with the thing most likely to alarm someone
looking at a list of 14,000 ignored photos: none of them has been deleted
or changed, and a file they fix comes back on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gosku
gosku force-pushed the library-sync-removes-missing-images branch from b337b89 to 20e6263 Compare August 9, 2026 11:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant