"""Widen image_record.phash to 256-bit and re-hash the library (issue #4223). The operator reported a 15-image variant pack landing as 3 records, and then that variants were STILL being dropped with `phash_threshold` at 0. Zero was already the floor of the dial, so no setting could have fixed it: at `hash_size=8` a pHash is 64 bits of coarse light/dark layout, and variant artwork sharing a composition produces the SAME 64 bits. Distance 0 meant "identical hash", never "identical image". `utils/phash.py` moves to `hash_size=16` (256 bits, what ImageRepo always used) and adds an aspect-ratio gate plus a pixel-level confirm, so a merge is accepted on the files rather than on the hash. ## Why this NULLs every phash Widening the column does not correct the values already in it. Every stored hash is a 64-bit hash of an image the app will now hash at 256 bits, and the two cannot be compared — `find_similar` skips a mismatched-length candidate rather than guessing, so leaving them would silently mean "no dedup, forever, for everything imported before today". NULL is the state `backfill_phash` already knows how to repair: it is NULL-only, keyset-paginated and restart-safe, and the beat schedule runs it daily. Until that backfill finishes, image dedup degrades to sha256 only — duplicates may be kept. That is the safe direction, and the only one available: the alternative is comparing hashes of different widths, which would drop artwork. NOTHING here deletes or supersedes a file. ## Why the threshold is reset rather than carried over `phash_threshold` counts bits, and the denominator went from 64 to 256. The stored number would keep its value while meaning something four times tighter. There is no honest carry-over, so every row goes to the new default of 24 — including the operator's 0, which was a workaround for the bug this revision fixes. Revision ID: 0098 Revises: 0097 Create Date: 2026-09-21 """ from typing import Sequence, Union import sqlalchemy as sa from alembic import op revision: str = "0098" down_revision: Union[str, None] = "0097" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # varchar(32) -> varchar(64): widening a length limit is a catalog-only # change in Postgres, so this does not rewrite the table or its index. op.alter_column( "image_record", "phash", existing_type=sa.String(32), type_=sa.String(64), existing_nullable=True, ) op.execute("UPDATE image_record SET phash = NULL WHERE phash IS NOT NULL") op.alter_column( "import_settings", "phash_threshold", existing_type=sa.Integer(), server_default="24", existing_nullable=False, ) op.execute("UPDATE import_settings SET phash_threshold = 24") def downgrade() -> None: # The 64-bit hashes this replaced are gone, and a 64-char value does not # fit back into varchar(32) — so the column is cleared again on the way # down and left for backfill_phash to refill at whatever HASH_SIZE the # code is running. Rule #22: no legacy to preserve. op.execute("UPDATE image_record SET phash = NULL WHERE phash IS NOT NULL") op.alter_column( "image_record", "phash", existing_type=sa.String(64), type_=sa.String(32), existing_nullable=True, ) op.alter_column( "import_settings", "phash_threshold", existing_type=sa.Integer(), server_default="10", existing_nullable=False, ) op.execute("UPDATE import_settings SET phash_threshold = 10")