Merge pull request 'fix(posts): link duplicate items to every post + prune bare shells' (#88) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m19s
Build images / build-ml (push) Successful in 2m41s
CI / integration (push) Successful in 3m5s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m19s
Build images / build-ml (push) Successful in 2m41s
CI / integration (push) Successful in 3m5s
This commit was merged in pull request #88.
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -6,6 +6,7 @@ Five action surfaces:
|
||||
DELETE /api/admin/tags/<int:tag_id> (Tier B)
|
||||
POST /api/admin/tags/<int:dest_id>/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/<int:tag_id>/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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<v-card class="fc-post-maint">
|
||||
<v-card-title class="d-flex align-center" style="gap: 10px;">
|
||||
<v-icon icon="mdi-post-outline" size="small" />
|
||||
<span>Post maintenance</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="fc-muted text-body-2 mb-4">
|
||||
Remove <strong>bare posts</strong> — post rows with no images (neither
|
||||
their own nor a cross-posted duplicate) and no attachments. These are the
|
||||
empty “Post 12345 / (no description)” shells a backfill can leave when a
|
||||
post's only content was a duplicate that links to an earlier post. Posts
|
||||
that gained a duplicate-image link are spared.
|
||||
</p>
|
||||
|
||||
<v-alert
|
||||
v-if="store.lastError"
|
||||
type="warning" variant="tonal" density="compact" class="mb-3"
|
||||
>{{ store.lastError }}</v-alert>
|
||||
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-magnify"
|
||||
:loading="loadingPreview"
|
||||
class="mb-3"
|
||||
@click="onPreview"
|
||||
>Preview bare posts</v-btn>
|
||||
|
||||
<div v-if="preview">
|
||||
<p class="text-body-2 mb-2">
|
||||
<strong>{{ preview.count }}</strong> bare post(s).
|
||||
<span v-if="preview.count > 50" class="fc-muted">
|
||||
Showing first 50.
|
||||
</span>
|
||||
<span v-else-if="!preview.count" class="fc-muted">
|
||||
Nothing to clean up.
|
||||
</span>
|
||||
</p>
|
||||
<div v-if="preview.sample_names?.length" class="fc-name-grid mb-3">
|
||||
<span v-for="(n, i) in preview.sample_names" :key="i" class="fc-name">
|
||||
{{ n }}
|
||||
</span>
|
||||
</div>
|
||||
<v-btn
|
||||
color="error" variant="flat" rounded="pill"
|
||||
prepend-icon="mdi-delete-sweep"
|
||||
:disabled="!preview.count"
|
||||
:loading="committing"
|
||||
@click="onCommit"
|
||||
>Delete {{ preview.count }} bare post(s)</v-btn>
|
||||
<span
|
||||
v-if="deleted != null"
|
||||
class="ml-3 text-caption text-success"
|
||||
>Deleted {{ deleted }} ✓</span>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useAdminStore } from '../../stores/admin.js'
|
||||
|
||||
const store = useAdminStore()
|
||||
const preview = ref(null)
|
||||
const loadingPreview = ref(false)
|
||||
const committing = ref(false)
|
||||
const deleted = ref(null)
|
||||
|
||||
async function onPreview() {
|
||||
loadingPreview.value = true
|
||||
deleted.value = null
|
||||
try {
|
||||
preview.value = await store.pruneBarePosts({ dryRun: true })
|
||||
} finally {
|
||||
loadingPreview.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onCommit() {
|
||||
committing.value = true
|
||||
try {
|
||||
const result = await store.pruneBarePosts({ dryRun: false })
|
||||
deleted.value = result.deleted ?? 0
|
||||
// Reflect the completed sweep — the predicate is identical to the preview,
|
||||
// so after a commit there is nothing left to delete.
|
||||
preview.value = { count: 0, sample_names: [] }
|
||||
} finally {
|
||||
committing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-post-maint { border-radius: 8px; }
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
.fc-name-grid {
|
||||
display: flex; flex-wrap: wrap; gap: 4px 8px;
|
||||
max-height: 200px; overflow-y: auto;
|
||||
padding: 8px; border-radius: 4px;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
}
|
||||
.fc-name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
</style>
|
||||
@@ -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,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<MinDimensionCard class="mb-4" />
|
||||
<TransparencyAuditCard class="mb-4" />
|
||||
<SingleColorAuditCard class="mb-4" />
|
||||
<PostMaintenanceCard class="mb-4" />
|
||||
<TagMaintenanceCard />
|
||||
</div>
|
||||
</template>
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ---------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user