a8f624a0f1
The native Patreon backfill flooded the feed with bare 'Post <id>' shells (1589 for Anduo). Root cause: PostAttachment.sha256 was GLOBALLY unique, so a non-art file reused across posts only ever linked to the first one, and _capture_attachment created the Post before that dedup check — leaving later posts with no image and no attachment. Duplicate IMAGES had the mirror gap: attach_in_place returned duplicate_hash/duplicate_phash before _apply_sidecar, so the second post got no provenance row, and the feed only rendered via primary_post_id (one post per image). Operator requirement: a duplicate item must show on EVERY post it appears in. Unify the fix as link-not-suppress: - importer: on duplicate_hash / duplicate_phash(larger_exists), append an image_provenance row for the new post (keep primary on the first). Both the download path (attach_in_place) and the filesystem path (_import_media). - post_feed_service: render thumbnails by image_provenance UNION primary_post_id, so a cross-posted image shows on every post (and legacy primary-only images still show). - PostAttachment: per-post uniqueness — drop UNIQUE(sha256), add partial UNIQUE(post_id, sha256) + partial UNIQUE(sha256) WHERE post_id IS NULL (migration 0043); _capture_attachment dedups per-(post,sha) over the shared sha-addressed blob, so no post is left bare. - cleanup: new prune-bare-posts maintenance action (cleanup_service _bare_post_conditions shared by preview/count/delete per preview/apply parity; admin endpoint; PostMaintenanceCard). Deletes posts with zero image links (primary or provenance) AND zero attachments. Run after the feed fix so a hidden provenance link spares the post instead of deleting it. Tests: dup image shows on both posts; dup attachment shows on both posts; feed renders provenance-linked duplicates; prune-bare delete-path == preview. Operator redeploys (migration 0043) then runs the prune to clear the shells. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
68 lines
2.3 KiB
Python
68 lines
2.3 KiB
Python
"""PostAttachment — a non-art file preserved from a post.
|
|
|
|
Art images become ImageRecords; everything else a post contained
|
|
(archives, .exe, .pdf, ...) is captured here so nothing is lost.
|
|
post_id is nullable (set only when an adjacent sidecar yields a Post);
|
|
artist_id mirrors the canonical attribution model (FC-2d-vii-c). Both
|
|
FKs are SET NULL so deleting a Post/Artist never deletes the preserved
|
|
binary row.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy import (
|
|
BigInteger,
|
|
DateTime,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
String,
|
|
Text,
|
|
func,
|
|
text,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from .base import Base
|
|
|
|
|
|
class PostAttachment(Base):
|
|
__tablename__ = "post_attachment"
|
|
# Dedup is PER-POST, not global (2026-06-08): the same non-art file attached
|
|
# to many posts gets one row per post over a single sha-addressed blob, so no
|
|
# post is left a bare shell. Partial uniques: (post_id, sha256) for real posts;
|
|
# (sha256) alone for the NULL-post filesystem case (one row per file there).
|
|
__table_args__ = (
|
|
Index(
|
|
"uq_post_attachment_post_sha",
|
|
"post_id", "sha256",
|
|
unique=True,
|
|
postgresql_where=text("post_id IS NOT NULL"),
|
|
),
|
|
Index(
|
|
"uq_post_attachment_null_post_sha",
|
|
"sha256",
|
|
unique=True,
|
|
postgresql_where=text("post_id IS NULL"),
|
|
),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
post_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("post.id", ondelete="SET NULL"), nullable=True, index=True
|
|
)
|
|
artist_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
|
|
)
|
|
sha256: Mapped[str] = mapped_column(
|
|
String(64), nullable=False, index=True
|
|
)
|
|
path: Mapped[str] = mapped_column(Text, nullable=False)
|
|
original_filename: Mapped[str] = mapped_column(Text, nullable=False)
|
|
ext: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
mime: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
|
captured_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|