From 9276b31b994c7f085c5fc5d82d52fc752bcc21f0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 24 Apr 2026 08:58:51 -0400 Subject: [PATCH] feat(maintenance): backfill fandom tags onto legacy character attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Celery task sync_character_fandoms_to_images on the maintenance queue. For every character tag with a non-null fandom_id, runs the same INSERT ... SELECT ... ON CONFLICT DO NOTHING that set_tag_fandom already runs inline, attaching the fandom tag to every image that has the character attached. Why this exists: migration j26042101 extracted the '(Fandom)' suffix from pre-refactor character names into tag.fandom_id, but it did NOT walk image_tags to attach the fandom rows to every image that had the character. Post-refactor auto-apply (in add_tag / accept_image_suggestion / set_tag_fandom) only fires on NEW add/accept events — pre-existing character attachments from before the migration still show the character pill in the modal but no fandom pill, because the fandom row was never added to image_tags. Surfaced by Settings → Maintenance → 'Sync fandoms to images'. Safe, additive, idempotent. Run once after the migration to close the gap; subsequent set-fandom operations continue to maintain the invariant inline. Verified locally: seeded a character+fandom pair with only the character attached to image 1; task added the fandom to image_tags; final state has both rows. Co-Authored-By: Claude Opus 4.7 --- app/celery_app.py | 1 + app/main.py | 7 ++++ app/tasks/maintenance.py | 64 +++++++++++++++++++++++++++++++++++++ app/templates/settings.html | 12 +++++++ 4 files changed, 84 insertions(+) diff --git a/app/celery_app.py b/app/celery_app.py index 0bf1df1..72f484f 100644 --- a/app/celery_app.py +++ b/app/celery_app.py @@ -72,6 +72,7 @@ def make_celery(app=None): 'app.tasks.scan.update_system_stats': {'queue': 'maintenance'}, 'app.tasks.scan.update_batch_stats': {'queue': 'maintenance'}, 'app.tasks.maintenance.sweep_blocklisted_tag_from_images': {'queue': 'maintenance'}, + 'app.tasks.maintenance.sync_character_fandoms_to_images': {'queue': 'maintenance'}, # Import tasks - handled by worker (heavy processing) 'app.tasks.import_file.*': {'queue': 'import'}, diff --git a/app/main.py b/app/main.py index 9d21c17..d55b2d9 100644 --- a/app/main.py +++ b/app/main.py @@ -641,6 +641,13 @@ def trigger_recompute_all_centroids(): return redirect(url_for('main.settings', tab='maintenance')) +@main.post('/settings/maintenance/sync-character-fandoms-to-images') +def trigger_sync_character_fandoms_to_images(): + from app.tasks.maintenance import sync_character_fandoms_to_images + sync_character_fandoms_to_images.apply_async(queue='maintenance') + return redirect(url_for('main.settings', tab='maintenance')) + + # ---------------------------- # Tag add/remove endpoints # ---------------------------- diff --git a/app/tasks/maintenance.py b/app/tasks/maintenance.py index 3d3d741..1429b89 100644 --- a/app/tasks/maintenance.py +++ b/app/tasks/maintenance.py @@ -15,6 +15,70 @@ from app.models import Tag log = logging.getLogger(__name__) +@shared_task( + name='app.tasks.maintenance.sync_character_fandoms_to_images', + soft_time_limit=300, + time_limit=600, +) +def sync_character_fandoms_to_images() -> dict: + """For every character tag with a non-null fandom_id, ensure the fandom + tag is attached to every image that has the character attached. + + Backfills the gap left by migration j26042101 — the migration set + tag.fandom_id from the old '(Fandom)' suffix but did not retroactively + add fandom rows to image_tags. Set operations made AFTER the migration + via /api/tag//set-fandom do this inline, so running this task once + (or whenever the user suspects drift) is sufficient. + + Additive only — never removes fandom attachments. Idempotent via + ON CONFLICT DO NOTHING. + + Returns a summary with per-character counts so the result is + introspectable. + """ + chars = ( + Tag.query + .filter_by(kind='character') + .filter(Tag.fandom_id.isnot(None)) + .all() + ) + total_links_added = 0 + characters_processed = 0 + failures = 0 + for char in chars: + try: + result = db.session.execute( + text(""" + INSERT INTO image_tags (image_id, tag_id) + SELECT it.image_id, :fandom_id + FROM image_tags it + WHERE it.tag_id = :char_id + ON CONFLICT DO NOTHING + """), + {'char_id': char.id, 'fandom_id': char.fandom_id}, + ) + total_links_added += result.rowcount or 0 + db.session.commit() + characters_processed += 1 + except Exception as e: + db.session.rollback() + failures += 1 + log.warning( + "sync_character_fandoms_to_images: char tag %s (%s) failed: %s", + char.id, char.name, e, + ) + log.info( + "sync_character_fandoms_to_images: processed %d characters, added %d links, %d failures", + characters_processed, total_links_added, failures, + ) + return { + 'characters_scanned': len(chars), + 'characters_processed': characters_processed, + 'links_added': total_links_added, + 'characters_failed': failures, + } + + @shared_task( name='app.tasks.maintenance.sweep_blocklisted_tag_from_images', soft_time_limit=60, diff --git a/app/templates/settings.html b/app/templates/settings.html index da1bc05..e867ffd 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -456,6 +456,18 @@ +

+ For every character tag with a fandom assigned, attach the fandom + tag to every image already tagged with that character. Additive + only — never removes fandom attachments. Safe to re-run. Backfills + data migrated from pre-refactor tag names that embedded the + "(Fandom)" suffix, since the migration set tag.fandom_id but did + not retroactively attach the fandom row to image_tags. +

+
+ +
+