diff --git a/alembic/versions/0043_post_attachment_per_post_unique.py b/alembic/versions/0043_post_attachment_per_post_unique.py new file mode 100644 index 0000000..e8e38ce --- /dev/null +++ b/alembic/versions/0043_post_attachment_per_post_unique.py @@ -0,0 +1,62 @@ +"""post_attachment: per-post sha uniqueness (empty-post flood fix) + +Revision ID: 0043 +Revises: 0042 +Create Date: 2026-06-08 + +PostAttachment.sha256 was GLOBALLY unique, so a non-art file the creator attaches +to many posts (a standard pdf/zip/link-card) only ever got ONE row — on the first +post — leaving every later post a bare shell (no image, no attachment). The native +Patreon backfill of Anduo surfaced 1589 such shells (operator-flagged 2026-06-08). + +Switch to PER-POST uniqueness: the on-disk blob stays sha-deduped, but each post +gets its own row. Replace the unique sha256 index with a plain lookup index plus +two partial uniques — (post_id, sha256) for real posts and (sha256) for the +NULL-post filesystem case (still one row per file there). + +Existing data has ≤1 row per sha (the old global unique), so the new partial +uniques can't be violated on upgrade — no data backfill needed here. The bare-post +shells themselves are removed by the separate prune-empty-posts cleanup tool. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0043" +down_revision: Union[str, None] = "0042" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Drop the global unique index; recreate it as a plain (non-unique) lookup + # index so sha-based reads keep their index (matches the model's index=True). + op.drop_index("ix_post_attachment_sha256", table_name="post_attachment") + op.create_index( + "ix_post_attachment_sha256", "post_attachment", ["sha256"], + ) + op.create_index( + "uq_post_attachment_post_sha", "post_attachment", + ["post_id", "sha256"], unique=True, + postgresql_where=sa.text("post_id IS NOT NULL"), + ) + op.create_index( + "uq_post_attachment_null_post_sha", "post_attachment", + ["sha256"], unique=True, + postgresql_where=sa.text("post_id IS NULL"), + ) + + +def downgrade() -> None: + op.drop_index( + "uq_post_attachment_null_post_sha", table_name="post_attachment" + ) + op.drop_index( + "uq_post_attachment_post_sha", table_name="post_attachment" + ) + op.drop_index("ix_post_attachment_sha256", table_name="post_attachment") + op.create_index( + "ix_post_attachment_sha256", "post_attachment", ["sha256"], + unique=True, + ) diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 69d058e..c33121a 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -6,6 +6,7 @@ Five action surfaces: DELETE /api/admin/tags/ (Tier B) POST /api/admin/tags//merge (Tier B) POST /api/admin/tags/prune-unused (Tier A) + POST /api/admin/posts/prune-bare (Tier A) POST /api/admin/tags/purge-legacy (Tier A) GET /api/admin/tags//usage-count (helper) @@ -204,6 +205,27 @@ async def tags_prune_unused(): return jsonify(result) +@admin_bp.route("/posts/prune-bare", methods=["POST"]) +async def posts_prune_bare(): + """Tier-A: delete bare posts — Post rows with no linked images (primary OR + provenance) and no attachments. Dry-run preview list IS the prompt: UI calls + with dry_run=true first, shows the count + sample, operator confirms by + re-calling with dry_run=false. Same preview/apply-parity predicate as the + prune itself, so the preview can't diverge from the delete.""" + from ..services.cleanup_service import prune_bare_posts + + body = await request.get_json(silent=True) or {} + dry_run = bool(body.get("dry_run", False)) + + async with get_session() as session: + result = await session.run_sync( + lambda sync_sess: prune_bare_posts( + sync_sess, dry_run=dry_run, + ) + ) + return jsonify(result) + + @admin_bp.route("/tags/purge-legacy", methods=["POST"]) async def tags_purge_legacy(): """Tier-A: delete legacy IR-migration tags — archive/post/artist diff --git a/backend/app/models/post_attachment.py b/backend/app/models/post_attachment.py index 4fea125..1edb7f3 100644 --- a/backend/app/models/post_attachment.py +++ b/backend/app/models/post_attachment.py @@ -14,10 +14,12 @@ from sqlalchemy import ( BigInteger, DateTime, ForeignKey, + Index, Integer, String, Text, func, + text, ) from sqlalchemy.orm import Mapped, mapped_column @@ -26,6 +28,24 @@ 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( @@ -35,7 +55,7 @@ class PostAttachment(Base): ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True ) sha256: Mapped[str] = mapped_column( - String(64), nullable=False, unique=True, index=True + String(64), nullable=False, index=True ) path: Mapped[str] = mapped_column(Text, nullable=False) original_filename: Mapped[str] = mapped_column(Text, nullable=False) diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py index 6788e5f..55b161c 100644 --- a/backend/app/services/cleanup_service.py +++ b/backend/app/services/cleanup_service.py @@ -20,7 +20,15 @@ from typing import Any from sqlalchemy import func, or_, select, update from sqlalchemy.orm import Session -from ..models import Artist, ImageRecord, LibraryAuditRun, Tag +from ..models import ( + Artist, + ImageProvenance, + ImageRecord, + LibraryAuditRun, + Post, + PostAttachment, + Tag, +) from ..models.series_chapter import SeriesChapter from ..models.series_page import SeriesPage from ..models.tag import image_tag @@ -407,6 +415,88 @@ def prune_unused_tags(session: Session, *, dry_run: bool = False) -> dict: return {"deleted": result.rowcount or 0, "sample_names": sample} +def _bare_post_conditions() -> list: + """The WHERE conditions that define a 'bare' post — the SINGLE source of truth + shared by find_bare_posts (preview sample), the dry-run count, AND the live + delete, so the preview can NEVER diverge from what the delete removes + ([[feedback_preview_apply_parity]]). + + A post is "bare" iff NOTHING is attached to it across every way content links + to a post: + - image_record.primary_post_id (a post's own canonical images) + - image_provenance.post_id (cross-posted/duplicate images linked here) + - post_attachment.post_id (preserved non-art files) + + These are exactly the shells the empty-post flood produced: the native Patreon + ingester synthesized a Post per walked post, but when its only content was a + duplicate image/attachment that linked to an EARLIER post, the new post was + left with none of the three (operator-flagged 2026-06-08). Every FK to post.id + is SET NULL or CASCADE, so deleting a bare post is non-destructive by + construction — there is nothing pointing at it to orphan. Must run AFTER the + provenance-render fix so a post that DOES have a hidden provenance link is + spared, not deleted. + """ + has_primary = select(ImageRecord.id).where( + ImageRecord.primary_post_id == Post.id + ) + has_provenance = select(ImageProvenance.id).where( + ImageProvenance.post_id == Post.id + ) + has_attachment = select(PostAttachment.id).where( + PostAttachment.post_id == Post.id + ) + return [ + ~has_primary.exists(), + ~has_provenance.exists(), + ~has_attachment.exists(), + ] + + +def find_bare_posts( + session: Session, *, limit: int | None = None, +) -> list[Post]: + """Posts with zero linked images (primary OR provenance) AND zero + attachments — safe to sweep. Sorted by id. Shares its predicate with the + live prune via _bare_post_conditions().""" + stmt = select(Post).where(*_bare_post_conditions()).order_by(Post.id) + if limit is not None: + stmt = stmt.limit(limit) + return list(session.execute(stmt).scalars().all()) + + +def _bare_post_label(post: Post) -> str: + """Human label for the preview sample — mirrors the feed's fallback title.""" + if post.post_title: + return post.post_title + return f"Post {post.external_post_id or post.id}" + + +def prune_bare_posts(session: Session, *, dry_run: bool = False) -> dict: + """Find posts with no images and no attachments and (unless dry_run) delete + them. + + Returns: + dry_run=True: {"count": N, "sample_names": [first 50]} + dry_run=False: {"deleted": N, "sample_names": [first 50]} + + The live delete runs a single DELETE with the SAME predicate + (_bare_post_conditions) the preview uses, so the row count scales without + binding every id as a parameter ([[reference_psycopg_65535_param_ceiling]]) + AND the delete can never remove a post the preview deemed kept. + """ + conditions = _bare_post_conditions() + sample_rows = find_bare_posts(session, limit=50) + sample = [_bare_post_label(p) for p in sample_rows] + if dry_run: + count = session.execute( + select(func.count()).select_from(Post).where(*conditions) + ).scalar_one() + return {"count": count, "sample_names": sample} + result = session.execute(Post.__table__.delete().where(*conditions)) + session.commit() + return {"deleted": result.rowcount or 0, "sample_names": sample} + + # Legacy tags FC no longer uses, in two shapes: # (1) kinds the tag input never produces — archive/post/artist. # provenance (post grouping) + archive membership are their own diff --git a/backend/app/services/importer.py b/backend/app/services/importer.py index c954bfe..a79da39 100644 --- a/backend/app/services/importer.py +++ b/backend/app/services/importer.py @@ -361,22 +361,39 @@ class Importer: artist = self._resolve_artist(source) post = self._post_for_sidecar(source, artist) sha = _sha256_of(source) - select_existing = select(PostAttachment).where(PostAttachment.sha256 == sha) + post_id = post.id if post else None + # Dedup is PER-POST, not global: a non-art file the creator attaches to + # many posts (a standard pdf/zip/link-card) must get a row on EVERY post + # so none is left a bare shell. The on-disk blob stays sha-deduped + # (attachments.store is sha-addressed + idempotent); only the rows are + # per-post. PostAttachment's unique is partial (post_id, sha256) for + # real posts and (sha256) for the NULL-post filesystem case. Before + # 2026-06-08 the global UNIQUE(sha256) left every post after the first + # with no attachment row → 1589 empty Anduo shells. + if post_id is not None: + select_existing = select(PostAttachment).where( + PostAttachment.post_id == post_id, + PostAttachment.sha256 == sha, + ) + else: + select_existing = select(PostAttachment).where( + PostAttachment.post_id.is_(None), + PostAttachment.sha256 == sha, + ) existing = self.session.execute(select_existing).scalar_one_or_none() if existing is not None: self.session.commit() return ImportResult(status="attached") - # Savepoint + IntegrityError recovery — PostAttachment.sha256 is - # UNIQUE, so two workers can both pass the SELECT and only the - # second INSERT fails. Without savepoint, the outer transaction - # poisons and the calling task crashes. attachments.store is - # sha-addressed so both workers race to write the same target - # path; shutil.copy2 + rename is idempotent. Audit 2026-06-02. + # Savepoint + IntegrityError recovery — the partial UNIQUE means two + # workers can both pass the SELECT and only the second INSERT fails. + # Without savepoint, the outer transaction poisons and the calling task + # crashes. attachments.store is sha-addressed so both workers race to + # write the same target path; shutil.copy2 + rename is idempotent. sp = self.session.begin_nested() try: stored = self.attachments.store(source, sha) self.session.add(PostAttachment( - post_id=post.id if post else None, + post_id=post_id, artist_id=artist.id if artist else None, sha256=sha, path=stored, @@ -389,7 +406,7 @@ class Importer: sp.commit() except IntegrityError: sp.rollback() - # Lost the race — the other worker's row is canonical. + # Lost the race — the other worker's row for this (post, sha) wins. self.session.execute(select_existing).scalar_one() self.session.commit() return ImportResult(status="attached") @@ -555,6 +572,15 @@ class Importer: existing = self.session.execute(existing_stmt).scalar_one_or_none() if existing: if not self.deep: + # Enrich-on-duplicate (parity with attach_in_place): a re-scanned + # file that is a byte-dup of an existing image still links its + # post via _apply_sidecar, so a cross-posted image shows on every + # post. Artist is derived from the sidecar here (not yet resolved). + self._apply_sidecar( + existing, attribution_path, None, + explicit_source=explicit_source, + ) + self.session.commit() return ImportResult( status="skipped", skip_reason=SkipReason.duplicate_hash, image_id=existing.id, error="sha256 already present", @@ -581,6 +607,14 @@ class Importer: candidates, self.settings.phash_threshold, ) if rel == "larger_exists": + # Enrich-on-duplicate (parity with attach_in_place). + larger = self.session.get(ImageRecord, match_id) + if larger is not None: + self._apply_sidecar( + larger, attribution_path, None, + explicit_source=explicit_source, + ) + self.session.commit() return ImportResult( status="skipped", skip_reason=SkipReason.duplicate_phash, @@ -764,6 +798,15 @@ class Importer: select(ImageRecord).where(ImageRecord.sha256 == sha) ).scalar_one_or_none() if existing: + # Enrich-on-duplicate (image_provenance docstring / spec §3): the + # same bytes appearing in another post must show on THAT post too, + # so append a provenance row for the new post rather than dropping + # the linkage. primary_post_id stays on the first post — _apply_sidecar + # only sets it when NULL. Without this the new post is left with no + # image AND (if its other content also deduped) no attachment, i.e. a + # bare shell — operator-flagged 2026-06-08 (1589 empty Anduo posts). + self._apply_sidecar(existing, path, artist, explicit_source=source) + self.session.commit() return ImportResult( status="skipped", skip_reason=SkipReason.duplicate_hash, image_id=existing.id, error="sha256 already present", @@ -784,6 +827,15 @@ class Importer: candidates, self.settings.phash_threshold, ) if rel == "larger_exists": + # Enrich-on-duplicate: link the near-dup's post to the + # existing larger image so it shows on both (see duplicate_hash + # above). smaller_exists already links via _supersede. + larger = self.session.get(ImageRecord, match_id) + if larger is not None: + self._apply_sidecar( + larger, path, artist, explicit_source=source, + ) + self.session.commit() return ImportResult( status="skipped", skip_reason=SkipReason.duplicate_phash, diff --git a/backend/app/services/post_feed_service.py b/backend/app/services/post_feed_service.py index 66d2745..f98debc 100644 --- a/backend/app/services/post_feed_service.py +++ b/backend/app/services/post_feed_service.py @@ -6,7 +6,8 @@ COALESCE(Post.post_date, Post.downloaded_at) so posts without a publish date sort by when we captured them. Pure read-surface; no writes. The service composes the post dict -(including thumbnails from ImageRecord.primary_post_id and non-media +(thumbnails from every image linked to the post — its own primary images +plus cross-posted duplicates via image_provenance — and non-media attachments from PostAttachment) so the API layer can jsonify directly. """ from __future__ import annotations @@ -17,7 +18,14 @@ from datetime import datetime from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from ..models import Artist, ImageRecord, Post, PostAttachment, Source +from ..models import ( + Artist, + ImageProvenance, + ImageRecord, + Post, + PostAttachment, + Source, +) from ..utils.text import html_to_plain, truncate_at_word from .gallery_service import thumbnail_url @@ -205,28 +213,50 @@ class PostFeedService: """ if not post_ids: return {} + # A post shows EVERY image linked to it — both its own primary images and + # cross-posted duplicates linked via image_provenance (a near-dup of an + # existing image gets a provenance row for the new post, not dropped; see + # image_provenance docstring + the importer enrich-on-duplicate path). The + # UNION dedups (image, post) pairs and also keeps any legacy image that has + # a primary_post_id but no provenance row. Partition the window on the + # link's post_id, not ImageRecord.primary_post_id, so a duplicate counts + # under each post it belongs to. + links = ( + select( + ImageProvenance.image_record_id.label("image_id"), + ImageProvenance.post_id.label("post_id"), + ) + .where(ImageProvenance.post_id.in_(post_ids)) + .union( + select( + ImageRecord.id.label("image_id"), + ImageRecord.primary_post_id.label("post_id"), + ).where(ImageRecord.primary_post_id.in_(post_ids)) + ) + .subquery() + ) # Rank images within each post; cap at `limit` rows per post when # limit is set, return all when limit is None. ranked = ( select( ImageRecord.id, - ImageRecord.primary_post_id, + links.c.post_id, ImageRecord.sha256, ImageRecord.mime, ImageRecord.thumbnail_path, func.row_number().over( - partition_by=ImageRecord.primary_post_id, + partition_by=links.c.post_id, order_by=ImageRecord.id.asc(), ).label("rn"), func.count(ImageRecord.id).over( - partition_by=ImageRecord.primary_post_id, + partition_by=links.c.post_id, ).label("total"), ) - .where(ImageRecord.primary_post_id.in_(post_ids)) + .join(ImageRecord, ImageRecord.id == links.c.image_id) .subquery() ) stmt = select( - ranked.c.id, ranked.c.primary_post_id, + ranked.c.id, ranked.c.post_id, ranked.c.sha256, ranked.c.mime, ranked.c.thumbnail_path, ranked.c.total, ) if limit is not None: diff --git a/frontend/src/components/settings/PostMaintenanceCard.vue b/frontend/src/components/settings/PostMaintenanceCard.vue new file mode 100644 index 0000000..c49a3d9 --- /dev/null +++ b/frontend/src/components/settings/PostMaintenanceCard.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/frontend/src/stores/admin.js b/frontend/src/stores/admin.js index 06d4ed6..abf6af3 100644 --- a/frontend/src/stores/admin.js +++ b/frontend/src/stores/admin.js @@ -114,6 +114,21 @@ export const useAdminStore = defineStore('admin', () => { } } + // Tier-A: delete bare posts (no images + no attachments) — the empty-post + // flood shells. Preview/apply parity on the backend; UI previews then confirms. + async function pruneBarePosts({ dryRun = true } = {}) { + lastError.value = null + try { + return await api.post( + '/api/admin/posts/prune-bare', + { body: { dry_run: dryRun } }, + ) + } catch (e) { + lastError.value = e.message + throw e + } + } + async function purgeLegacyTags({ dryRun = true } = {}) { lastError.value = null try { @@ -192,6 +207,7 @@ export const useAdminStore = defineStore('admin', () => { mergeTags, tagUsageCount, pruneUnusedTags, + pruneBarePosts, purgeLegacyTags, resetContentTagging, normalizeTags, diff --git a/frontend/src/views/CleanupView.vue b/frontend/src/views/CleanupView.vue index f02e63a..9f56bda 100644 --- a/frontend/src/views/CleanupView.vue +++ b/frontend/src/views/CleanupView.vue @@ -9,6 +9,7 @@ + @@ -17,6 +18,7 @@ import MinDimensionCard from '../components/cleanup/MinDimensionCard.vue' import TransparencyAuditCard from '../components/cleanup/TransparencyAuditCard.vue' import SingleColorAuditCard from '../components/cleanup/SingleColorAuditCard.vue' +import PostMaintenanceCard from '../components/settings/PostMaintenanceCard.vue' // Reuse existing TagMaintenanceCard (FC-3k) as-is — it already handles // preview + commit of prune-unused-tags via the admin store. Operator // confirmed 2026-05-26: don't duplicate into a new UnusedTagsCard. diff --git a/tests/test_cleanup_service.py b/tests/test_cleanup_service.py index 4a0c420..18f5db7 100644 --- a/tests/test_cleanup_service.py +++ b/tests/test_cleanup_service.py @@ -8,7 +8,15 @@ re-read ORM attributes after a service mutates and re-fetches. import pytest from sqlalchemy import func, select -from backend.app.models import Artist, ImageRecord, Tag, TagKind +from backend.app.models import ( + Artist, + ImageProvenance, + ImageRecord, + Post, + PostAttachment, + Tag, + TagKind, +) from backend.app.models.series_chapter import SeriesChapter from backend.app.models.series_page import SeriesPage from backend.app.models.tag import image_tag @@ -399,6 +407,39 @@ def test_prune_unused_tags_commit_spares_fandom_and_chaptered_series( assert "GenuinelyUnused" not in surviving +def test_prune_bare_posts_spares_linked_and_deletes_bare(db_sync, tmp_path): + # The LIVE delete (not just the preview) shares _bare_post_conditions, so a + # post is spared if it has ANY link — a primary image, a provenance (cross- + # posted/duplicate) image, or an attachment — and only the truly-bare shell + # is deleted (the empty-post flood, operator-flagged 2026-06-08). + a = _make_artist(db_sync, slug="pb") + img = _make_image(db_sync, artist=a, path=str(tmp_path / "x.jpg"), sha256="a" * 64) + + bare = Post(artist_id=a.id, external_post_id="bare1") + has_primary = Post(artist_id=a.id, external_post_id="prim") + has_prov = Post(artist_id=a.id, external_post_id="prov") + has_att = Post(artist_id=a.id, external_post_id="att") + db_sync.add_all([bare, has_primary, has_prov, has_att]) + db_sync.flush() + + img.primary_post_id = has_primary.id + db_sync.add(ImageProvenance(image_record_id=img.id, post_id=has_prov.id)) + db_sync.add(PostAttachment( + post_id=has_att.id, artist_id=a.id, sha256="b" * 64, + path="/store/b", original_filename="f.pdf", ext=".pdf", size_bytes=1, + )) + db_sync.commit() + + # dry-run count agrees with the delete: only the 1 truly-bare post. + assert cleanup_service.prune_bare_posts(db_sync, dry_run=True)["count"] == 1 + result = cleanup_service.prune_bare_posts(db_sync, dry_run=False) + assert result["deleted"] == 1 + + surviving = db_sync.execute(select(Post.external_post_id)).scalars().all() + assert "bare1" not in surviving + assert set(surviving) == {"prim", "prov", "att"} + + # --- reset_content_tagging ------------------------------------------ diff --git a/tests/test_importer_attach_in_place.py b/tests/test_importer_attach_in_place.py index 8e81ef5..10752e1 100644 --- a/tests/test_importer_attach_in_place.py +++ b/tests/test_importer_attach_in_place.py @@ -150,6 +150,87 @@ def test_attach_in_place_phash_smaller_skips_without_modifying_disk( assert smaller.exists() +def test_attach_in_place_duplicate_image_links_both_posts(importer, db_sync): + """A byte-identical image appearing in a SECOND post must link to BOTH posts + (enrich-on-duplicate) so it shows on every post — not be silently dropped, + which left the second post a bare shell. Operator-flagged 2026-06-08.""" + from backend.app.models import ImageProvenance, Post + + images_root = importer.images_root + artist = Artist(name="Hana", slug="hana") + db_sync.add(artist) + db_sync.flush() + + first = images_root / "hana" / "patreon" / "post_a" / "img.jpg" + bytes_ = _make_jpg(first, color=(120, 40, 90)) + first.with_suffix(first.suffix + ".json").write_text( + '{"category": "patreon", "id": "111", "title": "Post 111"}' + ) + r1 = importer.attach_in_place(first, artist=artist) + assert r1.status == "imported" + + second = images_root / "hana" / "patreon" / "post_b" / "img.jpg" + second.parent.mkdir(parents=True, exist_ok=True) + second.write_bytes(bytes_) # identical bytes → sha256 duplicate + second.with_suffix(second.suffix + ".json").write_text( + '{"category": "patreon", "id": "222", "title": "Post 222"}' + ) + r2 = importer.attach_in_place(second, artist=artist) + assert r2.status == "skipped" + assert r2.skip_reason.value == "duplicate_hash" + assert r2.image_id == r1.image_id + + linked = db_sync.execute( + select(Post.external_post_id) + .join(ImageProvenance, ImageProvenance.post_id == Post.id) + .where(ImageProvenance.image_record_id == r1.image_id) + ).scalars().all() + assert set(linked) == {"111", "222"} # shows on BOTH posts + + primary = db_sync.execute( + select(Post.external_post_id) + .join(ImageRecord, ImageRecord.primary_post_id == Post.id) + .where(ImageRecord.id == r1.image_id) + ).scalar_one() + assert primary == "111" # primary stays on the first post + + +def test_attach_in_place_duplicate_attachment_links_both_posts(importer, db_sync): + """The same non-art file attached to two posts gets a row on EACH post over a + single sha-addressed blob — neither post is left bare. Operator-flagged + 2026-06-08 (1589 empty Anduo shells from globally-unique attachment sha).""" + from backend.app.models import Post, PostAttachment + + images_root = importer.images_root + artist = Artist(name="Iris", slug="iris") + db_sync.add(artist) + db_sync.flush() + + payload = b"the same readme creator attaches to every post" + a = images_root / "iris" / "patreon" / "post_a" / "readme.txt" + a.parent.mkdir(parents=True, exist_ok=True) + a.write_bytes(payload) + a.with_suffix(a.suffix + ".json").write_text( + '{"category": "patreon", "id": "301", "title": "Post 301"}' + ) + assert importer.attach_in_place(a, artist=artist).status == "attached" + + b = images_root / "iris" / "patreon" / "post_b" / "readme.txt" + b.parent.mkdir(parents=True, exist_ok=True) + b.write_bytes(payload) + b.with_suffix(b.suffix + ".json").write_text( + '{"category": "patreon", "id": "302", "title": "Post 302"}' + ) + assert importer.attach_in_place(b, artist=artist).status == "attached" + + linked = db_sync.execute( + select(Post.external_post_id) + .join(PostAttachment, PostAttachment.post_id == Post.id) + .where(PostAttachment.original_filename == "readme.txt") + ).scalars().all() + assert set(linked) == {"301", "302"} # one row per post, both present + + def test_attach_in_place_invalid_image_returns_skipped(importer): images_root = importer.images_root bad = images_root / "fred" / "bad.jpg" diff --git a/tests/test_post_feed_service.py b/tests/test_post_feed_service.py index 4924ab2..886e8a1 100644 --- a/tests/test_post_feed_service.py +++ b/tests/test_post_feed_service.py @@ -11,6 +11,7 @@ from sqlalchemy import select from backend.app.models import ( Artist, + ImageProvenance, ImageRecord, Post, PostAttachment, @@ -283,6 +284,42 @@ async def test_scroll_thumbnails_show_first_six_with_overflow_count(db): assert t["mime"] == "image/jpeg" +@pytest.mark.asyncio +async def test_scroll_thumbnails_include_provenance_linked_duplicates(db): + """A cross-posted/duplicate image is linked to a second post via + image_provenance (its primary_post_id stays on the first post). The feed + renders by provenance UNION primary, so the image shows on BOTH posts — + not just its primary. Operator requirement 2026-06-08.""" + artist = await _seed_artist(db, "alice-prov") + src = await _seed_source(db, artist.id, "patreon", "https://p/alice-prov") + primary_post = await _seed_post( + db, src.id, external_id="PRIMARY", post_date=datetime.now(UTC), + ) + other_post = await _seed_post( + db, src.id, external_id="OTHER", + post_date=datetime.now(UTC) + timedelta(minutes=1), + ) + img = ImageRecord( + path="/images/dup.jpg", sha256="d" * 64, size_bytes=10, + mime="image/jpeg", width=10, height=10, origin="downloaded", + primary_post_id=primary_post.id, artist_id=artist.id, + ) + db.add(img) + await db.flush() + # The duplicate links to the OTHER post via provenance only. + db.add(ImageProvenance(image_record_id=img.id, post_id=other_post.id)) + await db.commit() + + page = await PostFeedService(db).scroll(cursor=None, limit=10) + by_ext = {it["external_post_id"]: it for it in page["items"]} + assert len(by_ext["PRIMARY"]["thumbnails"]) == 1 + assert len(by_ext["OTHER"]["thumbnails"]) == 1 # shown via provenance + assert ( + by_ext["PRIMARY"]["thumbnails"][0]["image_id"] + == by_ext["OTHER"]["thumbnails"][0]["image_id"] + ) + + # --- Attachments composition ---------------------------------------------