"""ImageRecord — the gallery's primary entity, ported from ImageRepo. ML fields and thumbnails are declared now (in FC-1) so FC-2 can populate them without a schema migration. The SigLIP embedding column uses pgvector's Vector type — pgvector extension is enabled in the initial migration. """ from datetime import datetime from pgvector.sqlalchemy import Vector from sqlalchemy import ( BigInteger, DateTime, Enum, Float, ForeignKey, Integer, String, Text, func, ) from sqlalchemy.orm import Mapped, mapped_column from .base import Base ORIGIN_CHOICES = ("downloaded", "imported_filesystem", "uploaded") class ImageRecord(Base): __tablename__ = "image_record" id: Mapped[int] = mapped_column(Integer, primary_key=True) # On-disk identity path: Mapped[str] = mapped_column(Text, nullable=False, unique=True) sha256: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True) phash: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False) mime: Mapped[str] = mapped_column(String(64), nullable=False) width: Mapped[int | None] = mapped_column(Integer, nullable=True) height: Mapped[int | None] = mapped_column(Integer, nullable=True) # Video container duration (seconds); NULL for images. The Tier-1 video # near-dup key (#871): two videos of the same artist with matching duration # (+ aspect) are the same content across re-encodes — dedup like image pHash. duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True) # Integrity verification status. FC-2e populates this; FC-2a leaves rows at 'unknown'. # Values: 'unknown' (default), 'ok', 'corrupt', 'failed_verification'. integrity_status: Mapped[str] = mapped_column( String(24), nullable=False, default="unknown", index=True ) # Thumbnail (populated by FC-2) thumbnail_path: Mapped[str | None] = mapped_column(Text, nullable=True) # Source provenance for downloaded media (#830 Phase 2). `source_url` is the # CDN/origin URL the file was fetched from (debugging + future re-fetch). # `source_filehash` is the URL's 32-hex CDN identity segment # (utils.paths.filehash_from_url) — the JOIN KEY that maps a post body's # inline `` back to this local copy so the rendered body serves # our stored image instead of hotlinking the public source. Indexed for the # render-time lookup. NULL for filesystem-imported / pre-Phase-2 rows. source_url: Mapped[str | None] = mapped_column(Text, nullable=True) source_filehash: Mapped[str | None] = mapped_column( String(32), nullable=True, index=True ) # Origin / provenance pointers origin: Mapped[str] = mapped_column(Enum(*ORIGIN_CHOICES, name="origin_enum"), nullable=False) primary_post_id: Mapped[int | None] = mapped_column( ForeignKey("post.id", ondelete="SET NULL"), nullable=True, index=True ) # FC-2d-vii-c: canonical per-image artist (the single source of truth # for attribution; provenance posts remain lineage detail). artist_id: Mapped[int | None] = mapped_column( ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True ) # ML fields (populated by the ml-worker / GPU agent). 1152 = SigLIP-so400m # embedding dim; siglip_model_version stamps which model produced it (so an # operator model swap, #1190, can re-embed the stale rows). A different-dim # model would need a column-width migration. siglip_embedding: Mapped[list[float] | None] = mapped_column(Vector(1152), nullable=True) siglip_model_version: Mapped[str | None] = mapped_column(String(128), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) # Denormalized gallery sort key = COALESCE(primary post's post_date, # created_at) (alembic 0035). The gallery used to compute this as a # COALESCE across the Post outer join on every /scroll, which can't use # an index and re-sorted a large slice of the library per page (×10 with # the old serial batching). Materializing it lets the cursor scroll read # ix_image_record_effective_date directly. Maintained by the importer # (services/importer.py _apply_sidecar) when a primary post with a date # is linked; plain inserts keep the created_at-equivalent server default. effective_date: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), )