Step 2 of milestone #421. The disk survey counted FOLDERS; this counts ROWS,
which is the number that matters — every move in step 3 is a row update, and
`ImageRecord.path` is the only pointer at the bytes.
`library_layout.py` holds the decision in two shared pieces, and both halves
of the consolidation spread them rather than restating them (rule 93, the
_x_conditions shape from snippet #3087):
- `_misplaced_conditions(root, artist_id, slug)` — rows of one artist whose
file is not under that artist's directory. The prefix carries a trailing
separator deliberately: without it `ara` matches everything under
`arbuzbudesh/`, and one artist reads as fully placed while another's rows
are silently skipped. Both are real artists here, hence the test.
- `destination_for(path, root, slug)` — where a row's file belongs, or None
when it must not be moved: outside the images root, or under one of the
reserved stores (`thumbs`, `attachments`, `cookies`, `secrets`, `_backups`,
`_quarantine`). Relocating those would move the thumbnail cache or the
credential key into an artist folder.
`destination_for` diverges from `canonical_subdir` in exactly one case, and
the docstring says why: a file at the images ROOT with a known artist moves
under that artist here, where the import-time helper leaves it alone. The two
answer different questions — an empty subdir at import means no artist was
resolved, while a row that already carries an artist_id is an anomaly with a
known correct home. The 660 unattributed files have no artist_id at all, so
no predicate reaches them; they are counted and left for task #4247.
`GET /api/cleanup/layout` exposes it. `?check_disk=1` additionally stats every
destination for collisions and missing sources — the conditions the apply
refuses on — but it is off by default so the count-only pass answers "how big
is this" in seconds instead of timing the request out on NFS.
Nothing here writes; a test asserts that against both the row and the file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR