feat(maintenance): backfill fandom tags onto legacy character attachments

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 08:58:51 -04:00
parent 3b472cf519
commit 9276b31b99
4 changed files with 84 additions and 0 deletions
+1
View File
@@ -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'},
+7
View File
@@ -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
# ----------------------------
+64
View File
@@ -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/<id>/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,
+12
View File
@@ -456,6 +456,18 @@
<button type="submit" class="btn secondary-btn">Recompute all centroids</button>
</form>
<p class="settings-hint mt-paragraph">
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.
</p>
<form action="{{ url_for('main.trigger_sync_character_fandoms_to_images') }}" method="post" onsubmit="return confirm('Attach each character\\'s fandom tag to every image with that character?');">
<button type="submit" class="btn secondary-btn">Sync fandoms to images</button>
</form>
</section>
<section class="settings-section">