diff --git a/alembic/versions/0021_image_provenance_unique.py b/alembic/versions/0021_image_provenance_unique.py new file mode 100644 index 0000000..b9be941 --- /dev/null +++ b/alembic/versions/0021_image_provenance_unique.py @@ -0,0 +1,54 @@ +"""provenance-race: dedupe + UNIQUE(image_record_id, post_id) on image_provenance + +Revision ID: 0021 +Revises: 0020 +Create Date: 2026-05-26 + +Closes the race in Importer._apply_sidecar's existence-check + INSERT pattern. +Two workers writing for the same (image, post) pair both saw no existing row +and both inserted, leaving duplicates that then broke .scalar_one_or_none() +on every subsequent deep-scan rederive against those images +(MultipleResultsFound). Most plausibly seeded when the 5-min recovery sweep +re-enqueued a still-running long-import task and the second worker collided +with the first inside _apply_sidecar. + +Migration steps: + 1. DELETE all but min(id) per (image_record_id, post_id) pair. Operator's + DB had 2 affected pairs at write-time; harmless no-op if zero. + 2. Add UNIQUE constraint so the importer's new savepoint+IntegrityError + recovery path can trip on collision and re-select, mirroring + uq_source_artist_platform_url and uq_post_source_external_id. +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0021" +down_revision: Union[str, None] = "0020" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + DELETE FROM image_provenance ip1 + USING image_provenance ip2 + WHERE ip1.image_record_id = ip2.image_record_id + AND ip1.post_id = ip2.post_id + AND ip1.id > ip2.id + """ + ) + op.create_unique_constraint( + "uq_image_provenance_image_post", + "image_provenance", + ["image_record_id", "post_id"], + ) + + +def downgrade() -> None: + op.drop_constraint( + "uq_image_provenance_image_post", + "image_provenance", + type_="unique", + ) diff --git a/alembic/versions/0022_source_per_artist_platform.py b/alembic/versions/0022_source_per_artist_platform.py new file mode 100644 index 0000000..eaee97f --- /dev/null +++ b/alembic/versions/0022_source_per_artist_platform.py @@ -0,0 +1,181 @@ +"""source-collapse: one Source per (artist, platform) — consolidate junk per-post Sources + +Revision ID: 0022 +Revises: 0021 +Create Date: 2026-05-26 + +Closes the operator-flagged 2026-05-26 issue where the filesystem importer +called _find_or_create_source(url=sd.post_url), creating one Source row per +imported post URL. Operator's Atole artist had 406 Source rows where there +should have been 1 (the /cw/Atole subscription Source). + +Source represents a subscription feed (one per artist+platform — the +gallery-dl URL polled by the FC-3 downloader). Posts hang off it. The +filesystem importer was misusing Source as a per-post key. + +Migration steps per (artist_id, platform) group with >1 Source: + 1. Pick canonical — prefer a URL NOT matching '/posts/$' (real + campaign URL like /cw/Atole); else min(id). + 2. Reparent Posts and ImageProvenance off the other Sources onto canonical. + 3. Merge any Posts that collide on (canonical_source_id, external_post_id) + by repointing ImageProvenance + ImageRecord.primary_post_id to the + earliest Post, dedupe ImageProvenance, delete the loser Posts. + 4. Delete the orphan Source rows. + 5. If the canonical Source's URL still looks like a per-post URL (no + campaign URL existed among candidates), rewrite it to + 'sidecar::' so the artist detail page shows + something readable. +""" +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import text + +revision: str = "0022" +down_revision: Union[str, None] = "0021" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_POST_URL_RE = r"/posts/[^/]+$" + + +def upgrade() -> None: + conn = op.get_bind() + + # Find (artist_id, platform) groups with > 1 Source row. + groups = conn.execute(text(""" + SELECT artist_id, platform + FROM source + GROUP BY artist_id, platform + HAVING COUNT(*) > 1 + """)).fetchall() + + for artist_id, platform in groups: + rows = conn.execute( + text(""" + SELECT id, url FROM source + WHERE artist_id = :a AND platform = :p + ORDER BY id ASC + """), + {"a": artist_id, "p": platform}, + ).fetchall() + + # Canonical: first row whose URL doesn't look like a per-post URL; + # else min(id). + canonical_id = None + for sid, url in rows: + if not _matches_post_url(url): + canonical_id = sid + break + if canonical_id is None: + canonical_id = rows[0][0] + + other_ids = [sid for sid, _ in rows if sid != canonical_id] + if not other_ids: + continue + + # Reparent Posts off the other Sources. + conn.execute( + text(""" + UPDATE post SET source_id = :canonical + WHERE source_id = ANY(:others) + """), + {"canonical": canonical_id, "others": other_ids}, + ) + # Reparent ImageProvenance.source_id similarly (denormalized FK). + conn.execute( + text(""" + UPDATE image_provenance SET source_id = :canonical + WHERE source_id = ANY(:others) + """), + {"canonical": canonical_id, "others": other_ids}, + ) + + # Merge any Posts colliding on (canonical, external_post_id) after + # reparent. In practice within a single (artist, platform) group + # these should be rare — each gallery-dl post has a unique id — but + # safe to handle. + post_collisions = conn.execute( + text(""" + SELECT external_post_id, ARRAY_AGG(id ORDER BY id) AS ids + FROM post + WHERE source_id = :canonical + GROUP BY external_post_id + HAVING COUNT(*) > 1 + """), + {"canonical": canonical_id}, + ).fetchall() + for _epid, post_ids in post_collisions: + keep = post_ids[0] + drops = post_ids[1:] + conn.execute( + text(""" + UPDATE image_provenance SET post_id = :keep + WHERE post_id = ANY(:drops) + """), + {"keep": keep, "drops": drops}, + ) + conn.execute( + text(""" + UPDATE image_record SET primary_post_id = :keep + WHERE primary_post_id = ANY(:drops) + """), + {"keep": keep, "drops": drops}, + ) + # Repointed provenance rows may now collide on + # uq_image_provenance_image_post (alembic 0021). Same dedupe + # pattern: keep min(id) per (image_record_id, post_id). + conn.execute(text(""" + DELETE FROM image_provenance ip1 + USING image_provenance ip2 + WHERE ip1.image_record_id = ip2.image_record_id + AND ip1.post_id = ip2.post_id + AND ip1.id > ip2.id + """)) + conn.execute( + text("DELETE FROM post WHERE id = ANY(:drops)"), + {"drops": drops}, + ) + + # Drop the orphan Sources. + conn.execute( + text("DELETE FROM source WHERE id = ANY(:others)"), + {"others": other_ids}, + ) + + # If the canonical's URL still looks per-post (no campaign URL + # existed among the candidates), rewrite to a synthetic anchor so + # the artist detail page renders something readable. + canonical_url = conn.execute( + text("SELECT url FROM source WHERE id = :id"), + {"id": canonical_id}, + ).scalar_one() + if _matches_post_url(canonical_url): + slug = conn.execute( + text("SELECT slug FROM artist WHERE id = :id"), + {"id": artist_id}, + ).scalar_one() + conn.execute( + text(""" + UPDATE source + SET url = :new_url, enabled = false + WHERE id = :id + """), + { + "id": canonical_id, + "new_url": f"sidecar:{platform}:{slug}", + }, + ) + + +def downgrade() -> None: + # Lossy migration — orphan Sources deleted, Posts reparented, Posts + # merged. No safe downgrade. If you need to roll back the schema + # invariant, fork from 0021 and re-run filesystem imports. + pass + + +def _matches_post_url(url: str) -> bool: + """True if url ends with /posts/ (gallery-dl-style per-post URL).""" + import re + return bool(re.search(_POST_URL_RE, url or "")) diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 4a88c1e..b8dc7c4 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -38,6 +38,7 @@ def all_blueprints() -> list[Blueprint]: from .system_activity import system_activity_bp from .system_backup import system_backup_bp from .tags import tags_bp + from .thumbnails import thumbnails_bp return [ api_bp, attachments_bp, @@ -58,6 +59,7 @@ def all_blueprints() -> list[Blueprint]: allowlist_bp, aliases_bp, ml_admin_bp, + thumbnails_bp, sources_bp, platforms_bp, posts_bp, diff --git a/backend/app/api/import_admin.py b/backend/app/api/import_admin.py index 367305b..02f65d2 100644 --- a/backend/app/api/import_admin.py +++ b/backend/app/api/import_admin.py @@ -103,17 +103,22 @@ async def list_tasks(): @import_admin_bp.route("/retry-failed", methods=["POST"]) async def retry_failed(): + # Fold SELECT into UPDATE…WHERE…RETURNING — the prior SELECT-then- + # UPDATE-WHERE-id-IN pattern blew past psycopg's 65535-parameter + # ceiling once failed_ids exceeded ~65k rows. async with get_session() as session: - failed_ids = ( - await session.execute(select(ImportTask.id).where(ImportTask.status == "failed")) - ).scalars().all() + result = await session.execute( + update(ImportTask) + .where(ImportTask.status == "failed") + .values( + status="queued", error=None, + started_at=None, finished_at=None, + ) + .returning(ImportTask.id) + ) + failed_ids = [row[0] for row in result.all()] if not failed_ids: return jsonify({"retried": 0}) - await session.execute( - update(ImportTask) - .where(ImportTask.id.in_(failed_ids)) - .values(status="queued", error=None, started_at=None, finished_at=None) - ) await session.commit() from ..tasks.import_file import import_media_file @@ -138,28 +143,26 @@ async def clear_stuck(): autoretry-looped for 2 days after a corrupt-data PIL OSError. """ async with get_session() as session: - stuck_ids = ( - await session.execute( - select(ImportTask.id).where( - ImportTask.status.in_(["pending", "queued", "processing"]) - ) + # Fold SELECT into UPDATE…WHERE — see /retry-failed for the + # 65535-parameter ceiling rationale. rowcount is enough here + # because we don't need the ids afterward (no .delay()). + clear_result = await session.execute( + update(ImportTask) + .where( + ImportTask.status.in_(["pending", "queued", "processing"]) ) - ).scalars().all() - if stuck_ids: - await session.execute( - update(ImportTask) - .where(ImportTask.id.in_(stuck_ids)) - .values( - status="failed", - finished_at=datetime.now(UTC), - error=( - "manually cleared via /api/import/clear-stuck " - "— stuck in non-terminal state; retry once " - "underlying cause (corrupt file, missing model, " - "etc.) is resolved" - ), - ) + .values( + status="failed", + finished_at=datetime.now(UTC), + error=( + "manually cleared via /api/import/clear-stuck " + "— stuck in non-terminal state; retry once " + "underlying cause (corrupt file, missing model, " + "etc.) is resolved" + ), ) + ) + tasks_failed = clear_result.rowcount or 0 # Finalize any 'running' ImportBatch that no longer has any # active children. The "Scanning..." banner is driven by @@ -195,7 +198,7 @@ async def clear_stuck(): await session.commit() return jsonify({ - "tasks_failed": len(stuck_ids), + "tasks_failed": tasks_failed, "batches_finalized": finalized_batches, }) diff --git a/backend/app/api/thumbnails.py b/backend/app/api/thumbnails.py new file mode 100644 index 0000000..eff1491 --- /dev/null +++ b/backend/app/api/thumbnails.py @@ -0,0 +1,13 @@ +"""Thumbnail admin API: backfill trigger.""" + +from quart import Blueprint, jsonify + +thumbnails_bp = Blueprint("thumbnails", __name__, url_prefix="/api/thumbnails") + + +@thumbnails_bp.route("/backfill", methods=["POST"]) +async def trigger_backfill(): + from ..tasks.thumbnail import backfill_thumbnails + + r = backfill_thumbnails.delay() + return jsonify({"celery_task_id": r.id}), 202 diff --git a/backend/app/models/image_provenance.py b/backend/app/models/image_provenance.py index cd50887..ec3e756 100644 --- a/backend/app/models/image_provenance.py +++ b/backend/app/models/image_provenance.py @@ -1,14 +1,18 @@ """ImageProvenance — links an ImageRecord to a Post. -Many-to-one (one image, many provenance rows) enables the enrich-on-duplicate -rule (spec §3): when a downloaded image is a pHash dupe of an existing -record, we append a new provenance row to the existing record rather than -dropping the metadata. +One image can have many provenance rows — different posts each contribute +metadata (enrich-on-duplicate rule, spec §3: a downloaded image that is a +pHash dupe of an existing record gets a NEW provenance row for the new post +appended, rather than the metadata being dropped). But the (image, post) +pair is unique — alembic 0021 enforces uq_image_provenance_image_post +after operator-flagged 2026-05-26 saw _apply_sidecar's existence-check + +INSERT race plant duplicates that then broke .scalar_one_or_none() on +every later deep-scan rederive (MultipleResultsFound). """ from datetime import datetime -from sqlalchemy import JSON, DateTime, ForeignKey, Integer, func +from sqlalchemy import JSON, DateTime, ForeignKey, Integer, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column from .base import Base @@ -16,6 +20,12 @@ from .base import Base class ImageProvenance(Base): __tablename__ = "image_provenance" + __table_args__ = ( + UniqueConstraint( + "image_record_id", "post_id", + name="uq_image_provenance_image_post", + ), + ) id: Mapped[int] = mapped_column(Integer, primary_key=True) image_record_id: Mapped[int] = mapped_column( diff --git a/backend/app/services/artist_service.py b/backend/app/services/artist_service.py index 271778e..c6aff2b 100644 --- a/backend/app/services/artist_service.py +++ b/backend/app/services/artist_service.py @@ -111,12 +111,22 @@ class ArtistService: ) ).all() + post_count = ( + await self.session.execute( + select(func.count(func.distinct(Post.id))) + .select_from(Post) + .join(Source, Source.id == Post.source_id) + .where(Source.artist_id == aid) + ) + ).scalar_one() + return { "id": artist.id, "name": artist.name, "slug": artist.slug, "is_subscription": bool(artist.is_subscription), "image_count": int(image_count), + "post_count": int(post_count), "date_range": { "min": dmin.isoformat() if dmin else None, "max": dmax.isoformat() if dmax else None, diff --git a/backend/app/services/extension_service.py b/backend/app/services/extension_service.py index ff88c9c..ce34ed9 100644 --- a/backend/app/services/extension_service.py +++ b/backend/app/services/extension_service.py @@ -11,6 +11,7 @@ from __future__ import annotations import re from sqlalchemy import select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from ..models import Artist, Source @@ -97,20 +98,40 @@ class ExtensionService: raise UnknownPlatformError(f"no platform pattern matched {url!r}") async def _find_or_create_artist(self, raw_name: str) -> tuple[Artist, bool]: + """Race-safe find-or-create on Artist by slug. Mirrors the + savepoint + IntegrityError recovery pattern used in + Importer._find_or_create_source/post (see + reference_scalar_one_or_none_duplicates memory). Without this, + two concurrent quick-add-source calls hitting the same artist + would both miss the existence check and the second INSERT would + 500 against uq_artist_slug. + """ slug = slugify(raw_name) existing = (await self.session.execute( select(Artist).where(Artist.slug == slug) )).scalar_one_or_none() if existing is not None: return existing, False - artist = Artist(name=raw_name, slug=slug, is_subscription=True) - self.session.add(artist) - await self.session.flush() - return artist, True + sp = await self.session.begin_nested() + try: + artist = Artist(name=raw_name, slug=slug, is_subscription=True) + self.session.add(artist) + await self.session.flush() + await sp.commit() + return artist, True + except IntegrityError: + await sp.rollback() + recovered = (await self.session.execute( + select(Artist).where(Artist.slug == slug) + )).scalar_one() + return recovered, False async def _find_or_create_source( self, *, artist_id: int, platform: str, url: str, ) -> tuple[Source, bool]: + """Race-safe — same pattern as _find_or_create_artist above. The + uq_source_artist_platform_url constraint catches the duplicate + insert; we roll the savepoint back and re-select.""" existing = (await self.session.execute( select(Source).where( Source.artist_id == artist_id, @@ -120,8 +141,24 @@ class ExtensionService: )).scalar_one_or_none() if existing is not None: return existing, False - src = Source(artist_id=artist_id, platform=platform, url=url, enabled=True) - self.session.add(src) - await self.session.flush() + sp = await self.session.begin_nested() + try: + src = Source( + artist_id=artist_id, platform=platform, + url=url, enabled=True, + ) + self.session.add(src) + await self.session.flush() + await sp.commit() + except IntegrityError: + await sp.rollback() + recovered = (await self.session.execute( + select(Source).where( + Source.artist_id == artist_id, + Source.platform == platform, + Source.url == url, + ) + )).scalar_one() + return recovered, False await self.session.commit() return src, True diff --git a/backend/app/services/importer.py b/backend/app/services/importer.py index 3223ec7..e2b94eb 100644 --- a/backend/app/services/importer.py +++ b/backend/app/services/importer.py @@ -247,6 +247,62 @@ class Importer: ) ).scalar_one() + def _source_for_sidecar( + self, *, artist_id: int, platform: str, artist_slug: str, + ) -> Source: + """Filesystem-import sidecar Source resolver. + + Source represents a subscription feed (one per artist+platform — the + gallery-dl URL polled by the FC-3 downloader). The filesystem importer + used to call _find_or_create_source(url=sd.post_url), which created + one Source row per post URL — 100s of junk Sources per artist, all + with enabled=True, polluting the artist detail page and tricking the + subscription checker into trying to poll patreon post URLs as feeds. + Operator-flagged 2026-05-26. + + New behaviour: if any Source row exists for (artist_id, platform), + reuse it regardless of its URL — the artist's real subscription Source + (created by the downloader / extension / UI) is the canonical + attachment point for filesystem-imported posts. If none exists, create + ONE synthetic anchor with url='sidecar::' and + enabled=False (so the subscription checker doesn't poll it). + """ + existing = self.session.execute( + select(Source) + .where( + Source.artist_id == artist_id, + Source.platform == platform, + ) + .order_by(Source.id.asc()) + .limit(1) + ).scalar_one_or_none() + if existing is not None: + return existing + synthetic_url = f"sidecar:{platform}:{artist_slug}" + sp = self.session.begin_nested() + try: + row = Source( + artist_id=artist_id, + platform=platform, + url=synthetic_url, + enabled=False, + ) + self.session.add(row) + self.session.flush() + sp.commit() + return row + except IntegrityError: + sp.rollback() + return self.session.execute( + select(Source) + .where( + Source.artist_id == artist_id, + Source.platform == platform, + ) + .order_by(Source.id.asc()) + .limit(1) + ).scalar_one() + def _find_or_create_post( self, *, source_id: int, external_post_id: str, ) -> Post: @@ -315,9 +371,8 @@ class Importer: return None sd = parse_sidecar(data) platform = sd.platform or "unknown" - url = sd.post_url or f"sidecar:{platform}" - src = self._find_or_create_source( - artist_id=artist.id, platform=platform, url=url, + src = self._source_for_sidecar( + artist_id=artist.id, platform=platform, artist_slug=artist.slug, ) epid = sd.external_post_id or sc.stem return self._find_or_create_post( @@ -763,9 +818,9 @@ class Importer: src = explicit_source else: platform = sd.platform or "unknown" - url = sd.post_url or f"sidecar:{platform}" - src = self._find_or_create_source( - artist_id=artist.id, platform=platform, url=url, + src = self._source_for_sidecar( + artist_id=artist.id, platform=platform, + artist_slug=artist.slug, ) epid = sd.external_post_id or sc.stem @@ -784,6 +839,15 @@ class Importer: post.attachment_count = sd.attachment_count post.raw_metadata = sd.raw + # Race-safe (image_record_id, post_id) upsert — mirrors the + # _find_or_create_source/post savepoint pattern. The plain + # SELECT-then-INSERT pattern lost a race when two workers ran + # _apply_sidecar on the same (image, post) pair (e.g. the 5-min + # recovery sweep re-enqueued a still-running long import), planting + # duplicates that then broke .scalar_one_or_none() on every later + # deep-scan rederive (MultipleResultsFound). Alembic 0021 adds the + # uq_image_provenance_image_post UNIQUE so this savepoint actually + # trips on collision. exists = self.session.execute( select(ImageProvenance.id).where( ImageProvenance.image_record_id == record.id, @@ -791,14 +855,20 @@ class Importer: ) ).scalar_one_or_none() if exists is None: - self.session.add( - ImageProvenance( - image_record_id=record.id, - post_id=post.id, - source_id=src.id, - captured_metadata=sd.raw, + sp = self.session.begin_nested() + try: + self.session.add( + ImageProvenance( + image_record_id=record.id, + post_id=post.id, + source_id=src.id, + captured_metadata=sd.raw, + ) ) - ) + self.session.flush() + sp.commit() + except IntegrityError: + sp.rollback() if record.primary_post_id is None: record.primary_post_id = post.id self.session.flush() diff --git a/backend/app/tasks/maintenance.py b/backend/app/tasks/maintenance.py index 4ef590a..df854a3 100644 --- a/backend/app/tasks/maintenance.py +++ b/backend/app/tasks/maintenance.py @@ -54,41 +54,40 @@ def recover_interrupted_tasks() -> int: processing_cutoff = now - timedelta(minutes=STUCK_THRESHOLD_MINUTES) orphan_cutoff = now - timedelta(minutes=ORPHAN_PENDING_THRESHOLD_MINUTES) with SessionLocal() as session: - stuck_ids = session.execute( - select(ImportTask.id) + # Both sweeps used to be SELECT ids → UPDATE WHERE id IN (...) which + # blew past psycopg's 65535-parameter ceiling once a sweep covered + # tens of thousands of rows (operator hit it 2026-05-26 after the + # /import deep scan piled up orphans). Folding the SELECT into the + # UPDATE eliminates the IN-list entirely. RETURNING gives us back + # exactly the ids that flipped so the stuck sweep can still + # .delay() each one. + stuck_result = session.execute( + update(ImportTask) .where(ImportTask.status == "processing") .where(ImportTask.started_at < processing_cutoff) - ).scalars().all() + .values( + status="queued", + started_at=None, + error="recovered from stuck state", + ) + .returning(ImportTask.id) + ) + stuck_ids = [row[0] for row in stuck_result.all()] - orphan_ids = session.execute( - select(ImportTask.id) + orphan_result = session.execute( + update(ImportTask) .where(ImportTask.status.in_(["pending", "queued"])) .where(ImportTask.created_at < orphan_cutoff) - ).scalars().all() - - if not stuck_ids and not orphan_ids: - return 0 - - if stuck_ids: - session.execute( - update(ImportTask) - .where(ImportTask.id.in_(stuck_ids)) - .values(status="queued", started_at=None, error="recovered from stuck state") - ) - - if orphan_ids: - session.execute( - update(ImportTask) - .where(ImportTask.id.in_(orphan_ids)) - .values( - status="failed", - error=( - "orphan pending/queued swept by recover_interrupted_tasks " - "(scanner likely crashed mid-enqueue); retry via " - "/api/import/retry-failed" - ), - ) + .values( + status="failed", + error=( + "orphan pending/queued swept by recover_interrupted_tasks " + "(scanner likely crashed mid-enqueue); retry via " + "/api/import/retry-failed" + ), ) + ) + orphan_count = orphan_result.rowcount or 0 session.commit() @@ -97,7 +96,7 @@ def recover_interrupted_tasks() -> int: for tid in stuck_ids: import_media_file.delay(tid) - return len(stuck_ids) + len(orphan_ids) + return len(stuck_ids) + orphan_count @celery.task(name="backend.app.tasks.maintenance.cleanup_old_tasks") diff --git a/backend/app/tasks/thumbnail.py b/backend/app/tasks/thumbnail.py index dec644f..0061bb1 100644 --- a/backend/app/tasks/thumbnail.py +++ b/backend/app/tasks/thumbnail.py @@ -17,6 +17,30 @@ from ._sync_engine import sync_session_factory as _sync_session_factory IMAGES_ROOT = Path("/images") +THUMB_MAGIC_JPEG = b"\xff\xd8\xff" +THUMB_MAGIC_PNG = b"\x89PNG\r\n\x1a\n" + + +def _thumb_is_valid(path: Path) -> bool: + """Return True iff `path` exists and starts with a JPEG or PNG magic header. + + The on-disk thumbnail format is set by services/thumbnailer.py — JPEG for + opaque sources, PNG for alpha sources. Anything else (missing file, OSError, + truncated, wrong magic) is invalid. + """ + try: + with path.open("rb") as f: + head = f.read(12) + except OSError: + return False + if len(head) < 8: + return False + if head[:3] == THUMB_MAGIC_JPEG: + return True + if head[:8] == THUMB_MAGIC_PNG: + return True + return False + @celery.task( name="backend.app.tasks.thumbnail.generate_thumbnail", @@ -50,3 +74,58 @@ def generate_thumbnail(self, image_id: int) -> dict: session.add(record) session.commit() return {"status": "ok", "image_id": image_id, "path": str(result.path)} + + +@celery.task( + name="backend.app.tasks.thumbnail.backfill_thumbnails", + bind=True, +) +def backfill_thumbnails(self) -> dict: + """Scan ImageRecord and enqueue generate_thumbnail for rows whose + thumbnail is missing, gone from disk, or has wrong magic bytes. + + Keyset paginates by id ASC, page size 500. NULLs out thumbnail_path for + rows that point at a missing or corrupt file before enqueueing — keeps + the DB self-consistent on partial runs and makes re-runs safe. + + Returns {"enqueued": N, "ok": M, "regenerated": K} where: + - enqueued = total generate_thumbnail.delay() calls + - ok = rows whose existing thumbnail file is valid (skipped) + - regenerated = subset of enqueued that had a non-NULL thumbnail_path + cleared (i.e. missing + corrupt) + """ + from sqlalchemy import select, update + + SessionLocal = _sync_session_factory() + enqueued = 0 + ok = 0 + regenerated = 0 + last_id = 0 + with SessionLocal() as session: + while True: + rows = session.execute( + select(ImageRecord.id, ImageRecord.thumbnail_path) + .where(ImageRecord.id > last_id) + .order_by(ImageRecord.id.asc()) + .limit(500) + ).all() + if not rows: + break + for image_id, thumb_path in rows: + if thumb_path is None: + generate_thumbnail.delay(image_id) + enqueued += 1 + elif _thumb_is_valid(Path(thumb_path)): + ok += 1 + else: + session.execute( + update(ImageRecord) + .where(ImageRecord.id == image_id) + .values(thumbnail_path=None) + ) + generate_thumbnail.delay(image_id) + enqueued += 1 + regenerated += 1 + session.commit() + last_id = rows[-1][0] + return {"enqueued": enqueued, "ok": ok, "regenerated": regenerated} diff --git a/frontend/src/components/artist/ArtistGalleryTab.vue b/frontend/src/components/artist/ArtistGalleryTab.vue new file mode 100644 index 0000000..0cc6b7c --- /dev/null +++ b/frontend/src/components/artist/ArtistGalleryTab.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/frontend/src/components/artist/ArtistHeader.vue b/frontend/src/components/artist/ArtistHeader.vue new file mode 100644 index 0000000..8b39cca --- /dev/null +++ b/frontend/src/components/artist/ArtistHeader.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/frontend/src/components/artist/ArtistManagementTab.vue b/frontend/src/components/artist/ArtistManagementTab.vue new file mode 100644 index 0000000..b78c2a1 --- /dev/null +++ b/frontend/src/components/artist/ArtistManagementTab.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/frontend/src/components/artist/ArtistPostsTab.vue b/frontend/src/components/artist/ArtistPostsTab.vue new file mode 100644 index 0000000..b575531 --- /dev/null +++ b/frontend/src/components/artist/ArtistPostsTab.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/frontend/src/components/common/ErrorDetailModal.vue b/frontend/src/components/common/ErrorDetailModal.vue index d8281a5..021f3da 100644 --- a/frontend/src/components/common/ErrorDetailModal.vue +++ b/frontend/src/components/common/ErrorDetailModal.vue @@ -4,14 +4,20 @@ - {{ title }} + {{ displayTitle }} mdi-close -
{{ message || '(no error message)' }}
+
+ +
+
{{ displayMessage || '(no error message)' }}
diff --git a/tests/test_api_artist.py b/tests/test_api_artist.py index 599093f..0a61d3b 100644 --- a/tests/test_api_artist.py +++ b/tests/test_api_artist.py @@ -29,6 +29,30 @@ async def test_artist_overview_ok(client, db): body = await resp.get_json() assert body["name"] == "Mira" assert body["image_count"] == 0 + assert body["post_count"] == 0 + + +@pytest.mark.asyncio +async def test_artist_overview_post_count(client, db): + from backend.app.models import Post, Source + + a = Artist(name="Lyra", slug="lyra") + db.add(a) + await db.flush() + s = Source( + artist_id=a.id, platform="patreon", + url="https://patreon.com/cw/lyra", enabled=True, + ) + db.add(s) + await db.flush() + db.add(Post(source_id=s.id, external_post_id="p1")) + db.add(Post(source_id=s.id, external_post_id="p2")) + await db.flush() + await db.commit() + resp = await client.get("/api/artist/lyra") + assert resp.status_code == 200 + body = await resp.get_json() + assert body["post_count"] == 2 @pytest.mark.asyncio diff --git a/tests/test_api_thumbnails.py b/tests/test_api_thumbnails.py new file mode 100644 index 0000000..b9d07a4 --- /dev/null +++ b/tests/test_api_thumbnails.py @@ -0,0 +1,32 @@ +import pytest + +from backend.app import create_app +from backend.app.celery_app import celery + +pytestmark = pytest.mark.integration + + +@pytest.fixture(autouse=True) +def eager(): + celery.conf.task_always_eager = True + yield + celery.conf.task_always_eager = False + + +@pytest.fixture +async def app(): + return create_app() + + +@pytest.fixture +async def client(app): + async with app.test_client() as c: + yield c + + +@pytest.mark.asyncio +async def test_trigger_thumbnail_backfill(client): + r = await client.post("/api/thumbnails/backfill") + assert r.status_code == 202 + body = await r.get_json() + assert "celery_task_id" in body diff --git a/tests/test_backfill_thumbnails.py b/tests/test_backfill_thumbnails.py new file mode 100644 index 0000000..faf2a77 --- /dev/null +++ b/tests/test_backfill_thumbnails.py @@ -0,0 +1,242 @@ +"""Thumbnail backfill: _thumb_is_valid helper + backfill_thumbnails planner.""" + +from pathlib import Path + +import pytest + +from backend.app.models import ImageRecord +from backend.app.tasks.thumbnail import _thumb_is_valid + +pytestmark = pytest.mark.integration + + +def test_thumb_is_valid_jpeg(tmp_path): + p = tmp_path / "good.jpg" + p.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) + assert _thumb_is_valid(p) is True + + +def test_thumb_is_valid_png(tmp_path): + p = tmp_path / "good.png" + p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100) + assert _thumb_is_valid(p) is True + + +def test_thumb_is_valid_too_short(tmp_path): + p = tmp_path / "tiny" + p.write_bytes(b"\xff\xd8") + assert _thumb_is_valid(p) is False + + +def test_thumb_is_valid_wrong_magic(tmp_path): + p = tmp_path / "garbage" + p.write_bytes(b"\x00" * 12) + assert _thumb_is_valid(p) is False + + +def test_thumb_is_valid_missing_file(tmp_path): + assert _thumb_is_valid(tmp_path / "nope") is False + + +# --- backfill_thumbnails planner tests ------------------------------------ + + +class _Ctx: + def __init__(self, s): + self.s = s + + def __enter__(self): + return self.s + + def __exit__(self, *a): + return False + + +def _sf(db_sync): + """sessionmaker-like returning the test's bound session, matching the + pattern in tests/test_backfill_phash.py.""" + class _SM: + def __call__(self): + return _Ctx(db_sync) + + return _SM() + + +def _sha(prefix: str) -> str: + return f"{prefix}".ljust(64, "0")[:64] + + +def _rec(db_sync, path, *, sha, thumb_path=None, mime="image/jpeg"): + rec = ImageRecord( + path=str(path), sha256=sha, size_bytes=1, mime=mime, + width=64, height=64, origin="imported_filesystem", + integrity_status="unknown", + thumbnail_path=str(thumb_path) if thumb_path is not None else None, + ) + db_sync.add(rec) + db_sync.flush() + return rec + + +def _write_jpeg(p: Path) -> Path: + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) + return p + + +def _write_png(p: Path) -> Path: + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100) + return p + + +def _write_garbage(p: Path) -> Path: + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(b"\x00" * 12) + return p + + +def test_backfill_null_path_enqueued(db_sync, tmp_path, monkeypatch): + from backend.app.tasks import thumbnail as m + + src = tmp_path / "a.bin" + src.write_bytes(b"x") + rec = _rec(db_sync, src, sha=_sha("a"), thumb_path=None) + db_sync.commit() + + monkeypatch.setattr(m, "_sync_session_factory", lambda: _sf(db_sync)) + delayed: list[int] = [] + monkeypatch.setattr( + m.generate_thumbnail, "delay", lambda image_id: delayed.append(image_id) + ) + + result = m.backfill_thumbnails() + assert result == {"enqueued": 1, "ok": 0, "regenerated": 0} + assert delayed == [rec.id] + + +def test_backfill_missing_file_clears_and_enqueues(db_sync, tmp_path, monkeypatch): + from backend.app.tasks import thumbnail as m + + src = tmp_path / "b.bin" + src.write_bytes(b"x") + rec = _rec( + db_sync, src, sha=_sha("b"), + thumb_path=tmp_path / "thumbs" / "missing.jpg", + ) + db_sync.commit() + + monkeypatch.setattr(m, "_sync_session_factory", lambda: _sf(db_sync)) + delayed: list[int] = [] + monkeypatch.setattr( + m.generate_thumbnail, "delay", lambda image_id: delayed.append(image_id) + ) + + result = m.backfill_thumbnails() + db_sync.expire_all() + assert result == {"enqueued": 1, "ok": 0, "regenerated": 1} + assert delayed == [rec.id] + assert db_sync.get(ImageRecord, rec.id).thumbnail_path is None + + +def test_backfill_valid_jpeg_skipped(db_sync, tmp_path, monkeypatch): + from backend.app.tasks import thumbnail as m + + src = tmp_path / "c.bin" + src.write_bytes(b"x") + thumb = _write_jpeg(tmp_path / "thumbs" / "c.jpg") + rec = _rec(db_sync, src, sha=_sha("c"), thumb_path=thumb) + db_sync.commit() + + monkeypatch.setattr(m, "_sync_session_factory", lambda: _sf(db_sync)) + delayed: list[int] = [] + monkeypatch.setattr( + m.generate_thumbnail, "delay", lambda image_id: delayed.append(image_id) + ) + + result = m.backfill_thumbnails() + db_sync.expire_all() + assert result == {"enqueued": 0, "ok": 1, "regenerated": 0} + assert delayed == [] + assert db_sync.get(ImageRecord, rec.id).thumbnail_path == str(thumb) + + +def test_backfill_valid_png_skipped(db_sync, tmp_path, monkeypatch): + from backend.app.tasks import thumbnail as m + + src = tmp_path / "d.bin" + src.write_bytes(b"x") + thumb = _write_png(tmp_path / "thumbs" / "d.png") + _rec(db_sync, src, sha=_sha("d"), thumb_path=thumb) + db_sync.commit() + + monkeypatch.setattr(m, "_sync_session_factory", lambda: _sf(db_sync)) + delayed: list[int] = [] + monkeypatch.setattr( + m.generate_thumbnail, "delay", lambda image_id: delayed.append(image_id) + ) + + result = m.backfill_thumbnails() + assert result == {"enqueued": 0, "ok": 1, "regenerated": 0} + assert delayed == [] + + +def test_backfill_corrupt_magic_clears_and_enqueues(db_sync, tmp_path, monkeypatch): + from backend.app.tasks import thumbnail as m + + src = tmp_path / "e.bin" + src.write_bytes(b"x") + thumb = _write_garbage(tmp_path / "thumbs" / "e.jpg") + rec = _rec(db_sync, src, sha=_sha("e"), thumb_path=thumb) + db_sync.commit() + + monkeypatch.setattr(m, "_sync_session_factory", lambda: _sf(db_sync)) + delayed: list[int] = [] + monkeypatch.setattr( + m.generate_thumbnail, "delay", lambda image_id: delayed.append(image_id) + ) + + result = m.backfill_thumbnails() + db_sync.expire_all() + assert result == {"enqueued": 1, "ok": 0, "regenerated": 1} + assert delayed == [rec.id] + assert db_sync.get(ImageRecord, rec.id).thumbnail_path is None + + +def test_backfill_mixed_aggregate(db_sync, tmp_path, monkeypatch): + from backend.app.tasks import thumbnail as m + + src_null = tmp_path / "src_null.bin" + src_null.write_bytes(b"x") + src_jpeg = tmp_path / "src_jpeg.bin" + src_jpeg.write_bytes(b"x") + src_png = tmp_path / "src_png.bin" + src_png.write_bytes(b"x") + src_missing = tmp_path / "src_missing.bin" + src_missing.write_bytes(b"x") + src_bad = tmp_path / "src_bad.bin" + src_bad.write_bytes(b"x") + + jpeg = _write_jpeg(tmp_path / "thumbs" / "ok.jpg") + png = _write_png(tmp_path / "thumbs" / "ok.png") + bad = _write_garbage(tmp_path / "thumbs" / "bad.jpg") + + r_null = _rec(db_sync, src_null, sha=_sha("aa"), thumb_path=None) + _rec(db_sync, src_jpeg, sha=_sha("bb"), thumb_path=jpeg) + _rec(db_sync, src_png, sha=_sha("cc"), thumb_path=png) + r_missing = _rec( + db_sync, src_missing, sha=_sha("dd"), + thumb_path=tmp_path / "thumbs" / "missing.jpg", + ) + r_bad = _rec(db_sync, src_bad, sha=_sha("ee"), thumb_path=bad) + db_sync.commit() + + monkeypatch.setattr(m, "_sync_session_factory", lambda: _sf(db_sync)) + delayed: list[int] = [] + monkeypatch.setattr( + m.generate_thumbnail, "delay", lambda image_id: delayed.append(image_id) + ) + + result = m.backfill_thumbnails() + assert result == {"enqueued": 3, "ok": 2, "regenerated": 2} + assert sorted(delayed) == sorted([r_null.id, r_missing.id, r_bad.id]) diff --git a/tests/test_gallery_provenance_filter.py b/tests/test_gallery_provenance_filter.py index a7f2ba4..2eb93aa 100644 --- a/tests/test_gallery_provenance_filter.py +++ b/tests/test_gallery_provenance_filter.py @@ -69,20 +69,13 @@ async def test_scroll_post_id_filter(db): assert {x.id for x in page.images} == {i1.id, i2.id} -@pytest.mark.asyncio -async def test_scroll_post_id_dedups_multi_rows(db): - i1 = await _img(db, 1) - _, s, p = await _post(db, "A", "a", "10") - # two provenance rows, same image+post (enrich-on-duplicate shape) - db.add(ImageProvenance(image_record_id=i1.id, post_id=p.id, - source_id=s.id)) - await db.flush() - db.add(ImageProvenance(image_record_id=i1.id, post_id=p.id, - source_id=s.id)) - await db.flush() - svc = GalleryService(db) - page = await svc.scroll(cursor=None, limit=10, post_id=p.id) - assert [x.id for x in page.images] == [i1.id] # appears once +# test_scroll_post_id_dedups_multi_rows removed 2026-05-26: it deliberately +# inserted two ImageProvenance rows with the same (image_record_id, post_id), +# now prevented at the DB layer by uq_image_provenance_image_post (alembic +# 0021). The EXISTS-based dedup in _provenance_clause is still useful for the +# artist-id filter (one image legitimately joins many provenance rows via +# different posts under the same artist), so the gallery_service logic is +# unchanged. @pytest.mark.asyncio diff --git a/tests/test_importer_provenance_race.py b/tests/test_importer_provenance_race.py new file mode 100644 index 0000000..68ba96c --- /dev/null +++ b/tests/test_importer_provenance_race.py @@ -0,0 +1,184 @@ +"""Race-safe ImageProvenance insert in Importer._apply_sidecar. + +Operator-flagged 2026-05-26: the prior SELECT-then-INSERT pattern lost a +race when two workers ran _apply_sidecar on the same (image, post) pair +(plausibly seeded when the 5-min recovery sweep re-enqueued a still-running +long import). Duplicates then broke .scalar_one_or_none() on every later +deep-scan rederive (MultipleResultsFound). Alembic 0021 added +uq_image_provenance_image_post; the importer's new savepoint+IntegrityError +recovery path now trips on collision and gracefully recovers. + +Tests cover: + - idempotent: re-running _apply_sidecar via _deep_rederive produces + exactly one ImageProvenance row. + - IntegrityError recovery: pre-seed a provenance row, force the first + SELECT to return None (simulating the race window where two workers + both observed no row), call _apply_sidecar — the savepoint INSERT + trips uq_image_provenance_image_post, gets rolled back, no exception + escapes, still exactly one row. +""" + +import json +from pathlib import Path + +import pytest +from PIL import Image +from sqlalchemy import func, select + +from backend.app.models import ( + ImageProvenance, + ImageRecord, + ImportSettings, + Source, +) +from backend.app.services.importer import Importer +from backend.app.services.thumbnailer import Thumbnailer + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def import_layout(tmp_path): + import_root = tmp_path / "import" + images_root = tmp_path / "images" + import_root.mkdir() + images_root.mkdir() + return import_root, images_root + + +@pytest.fixture +def importer(db_sync, import_layout): + import_root, images_root = import_layout + settings = db_sync.execute( + select(ImportSettings).where(ImportSettings.id == 1) + ).scalar_one() + return Importer( + session=db_sync, + images_root=images_root, + import_root=import_root, + thumbnailer=Thumbnailer(images_root=images_root), + settings=settings, + ) + + +@pytest.fixture +def deep_importer(db_sync, import_layout): + import_root, images_root = import_layout + settings = db_sync.execute( + select(ImportSettings).where(ImportSettings.id == 1) + ).scalar_one() + return Importer( + session=db_sync, + images_root=images_root, + import_root=import_root, + thumbnailer=Thumbnailer(images_root=images_root), + settings=settings, + deep=True, + ) + + +def _split(path: Path, orient, size=(256, 256)): + path.parent.mkdir(parents=True, exist_ok=True) + w, h = size + im = Image.new("L", size, 0) + px = im.load() + for y in range(h): + for x in range(w): + if (x / w if orient == "v" else y / h) >= 0.5: + px[x, y] = 255 + im.convert("RGB").save(path, "JPEG") + + +def _sidecar(media: Path, payload: dict): + media.with_suffix(".json").write_text(json.dumps(payload)) + + +def test_apply_sidecar_idempotent_on_deep_rederive( + importer, deep_importer, import_layout, +): + """Normal-flow path: deep rederive on an already-imported image finds + the existing provenance row via .scalar_one_or_none() and skips the + insert. Exactly one ImageProvenance row after both runs.""" + import_root, _ = import_layout + m = import_root / "Alice" / "a.jpg" + _split(m, "v") + _sidecar(m, { + "category": "patreon", "id": 555, + "url": "https://patreon.com/posts/555", "title": "Set 1", + }) + r = importer.import_one(m) + assert r.status == "imported" + + # Re-import via deep mode — sha matches → _deep_rederive → _apply_sidecar. + r2 = deep_importer.import_one(m) + assert r2.status == "refreshed" + + count = importer.session.execute( + select(func.count()).select_from(ImageProvenance) + ).scalar_one() + assert count == 1 + + +def test_apply_sidecar_recovers_from_integrity_error( + importer, deep_importer, import_layout, db_sync, monkeypatch, +): + """Race recovery: a row already exists for (image, post). We force the + importer's existence-check SELECT to return None for one call, mimicking + the race window where two workers both saw no row. The savepoint INSERT + then trips uq_image_provenance_image_post; the helper rolls the + savepoint back, no exception escapes, and the row count stays at 1. + """ + import_root, _ = import_layout + m = import_root / "Bob" / "b.jpg" + _split(m, "v") + _sidecar(m, { + "category": "patreon", "id": 777, + "url": "https://patreon.com/posts/777", "title": "Set 2", + }) + # First import lays the canonical provenance row. + r = importer.import_one(m) + assert r.status == "imported" + rec = importer.session.get(ImageRecord, r.image_id) + src = importer.session.execute(select(Source)).scalar_one() + assert rec is not None + assert src is not None + + # Monkeypatch session.execute so the FIRST select inside _apply_sidecar's + # existence-check returns a "no row" wrapper. Subsequent selects (e.g. + # the find_or_create_source / find_or_create_post existence checks + # earlier in _apply_sidecar) all run normally; we intercept only the + # ImageProvenance lookup, identified by the SELECT's target columns + # mentioning image_provenance. + real_execute = db_sync.execute + intercepted = [False] + + def _intercepting_execute(stmt, *args, **kwargs): + text = str(stmt) + if ( + not intercepted[0] + and "image_provenance" in text + and "image_record_id" in text + and "post_id" in text + ): + intercepted[0] = True + + class _ForcedMiss: + def scalar_one_or_none(self): + return None + return _ForcedMiss() + return real_execute(stmt, *args, **kwargs) + + monkeypatch.setattr(db_sync, "execute", _intercepting_execute) + + # Re-import via deep mode → _deep_rederive → _apply_sidecar. With the + # provenance-SELECT forced to miss, the helper will attempt the INSERT, + # trip uq_image_provenance_image_post, catch IntegrityError, and recover. + r2 = deep_importer.import_one(m) + assert r2.status == "refreshed" + + # Lift the intercept, then verify the row count. + monkeypatch.undo() + count = db_sync.execute( + select(func.count()).select_from(ImageProvenance) + ).scalar_one() + assert count == 1 diff --git a/tests/test_importer_upsert_helpers.py b/tests/test_importer_upsert_helpers.py index ca8a3bc..8472031 100644 --- a/tests/test_importer_upsert_helpers.py +++ b/tests/test_importer_upsert_helpers.py @@ -145,3 +145,68 @@ def test_find_or_create_source_recovers_from_integrity_error( artist_id=artist_row.id, platform="patreon", url=canonical_url, ) assert recovered.id == pre_existing.id + + +def test_source_for_sidecar_reuses_existing_subscription( + importer, artist_row, db_sync, +): + """The filesystem-import sidecar resolver should attach to whatever + Source already exists for (artist, platform) — the canonical subscription + Source — regardless of its URL. Without this, every imported post + spawned its own Source row. + """ + canonical = Source( + artist_id=artist_row.id, platform="patreon", + url="https://www.patreon.com/cw/testartist", enabled=True, + ) + db_sync.add(canonical) + db_sync.flush() + + resolved = importer._source_for_sidecar( + artist_id=artist_row.id, platform="patreon", + artist_slug=artist_row.slug, + ) + assert resolved.id == canonical.id + + +def test_source_for_sidecar_creates_synthetic_anchor_when_none_exists( + importer, artist_row, db_sync, +): + """No subscription Source for this (artist, platform) yet. The helper + creates one synthetic anchor (enabled=False, url='sidecar::') + so subsequent imports reuse it instead of spawning per-post Sources. + """ + resolved = importer._source_for_sidecar( + artist_id=artist_row.id, platform="pixiv", + artist_slug=artist_row.slug, + ) + assert resolved.url == f"sidecar:pixiv:{artist_row.slug}" + assert resolved.enabled is False + assert resolved.artist_id == artist_row.id + assert resolved.platform == "pixiv" + + # Second call returns the same row (no new Source spawned). + again = importer._source_for_sidecar( + artist_id=artist_row.id, platform="pixiv", + artist_slug=artist_row.slug, + ) + assert again.id == resolved.id + + +def test_source_for_sidecar_distinct_platforms_distinct_anchors( + importer, artist_row, db_sync, +): + """One synthetic anchor per (artist, platform). Different platforms get + different anchors even when no campaign Source exists for either. + """ + p = importer._source_for_sidecar( + artist_id=artist_row.id, platform="patreon", + artist_slug=artist_row.slug, + ) + x = importer._source_for_sidecar( + artist_id=artist_row.id, platform="pixiv", + artist_slug=artist_row.slug, + ) + assert p.id != x.id + assert p.platform == "patreon" + assert x.platform == "pixiv" diff --git a/tests/test_tasks_register.py b/tests/test_tasks_register.py index a8d4f72..11f457c 100644 --- a/tests/test_tasks_register.py +++ b/tests/test_tasks_register.py @@ -23,3 +23,7 @@ def test_import_media_file_registered(): def test_generate_thumbnail_registered(): assert "backend.app.tasks.thumbnail.generate_thumbnail" in celery.tasks + + +def test_backfill_thumbnails_registered(): + assert "backend.app.tasks.thumbnail.backfill_thumbnails" in celery.tasks