"""Blacklisted-tag cloud for the Followed page. The cloud counts tags from the user's e621 blacklist that appear in their followed feeds. Building it means one e621 request (the blacklist itself) plus a local scan of stored feed posts, so it runs in a daemon thread and the page polls until the fresh result lands. """ import logging import threading from datetime import timedelta from fnmatch import fnmatch from django.contrib.auth import get_user_model from django.db import connection from django.utils import timezone from apps.library import e621, guest_filter from .models import FollowCloud, FollowedPost logger = logging.getLogger(__name__) CLOUD_TTL = timedelta(minutes=10) BUILD_STALE = timedelta(minutes=5) MAX_CLOUD_TAGS = 60 def blacklist_patterns_for(user): """(patterns, source): the user's e621 blacklist, else the guest default.""" if e621.credentials_configured(user): try: me = e621.get(user, "/users/me.json") lines = str(me.get("blacklisted_tags") or "").splitlines() patterns = guest_filter.parse_blacklist_patterns(lines) if patterns: return patterns, "user" except e621.E621Error as exc: logger.warning("Could not fetch the e621 blacklist for %s: %s", user, exc) return guest_filter.guest_blacklist_patterns(), "default" def compute_cloud(user): """Count blacklist matches across every stored feed post of one user.""" patterns, source = blacklist_patterns_for(user) counts = {} seen_posts = set() rows = FollowedPost.objects.filter(user=user).values_list("post_id", "data") for post_id, data in rows: if post_id in seen_posts: continue seen_posts.add(post_id) if not isinstance(data, dict): continue categories = data.get("tags") if not isinstance(categories, dict): continue for values in categories.values(): if not isinstance(values, list): continue for tag in values: name = str(tag).strip().lower() if name and any(fnmatch(name, pattern) for pattern in patterns): counts[name] = counts.get(name, 0) + 1 tags = sorted(counts.items(), key=lambda entry: (-entry[1], entry[0])) return { "tags": [[tag, count] for tag, count in tags[:MAX_CLOUD_TAGS]], "posts": len(seen_posts), "source": source, "blacklist_count": len(patterns), } def start_cloud_build(user_id): thread = threading.Thread(target=run_cloud_build, args=(user_id,), daemon=True) thread.start() def run_cloud_build(user_id): User = get_user_model() try: user = User.objects.filter(id=user_id).first() if user is None: return data = compute_cloud(user) FollowCloud.objects.filter(user_id=user_id).update( data=data, computed_at=timezone.now() ) except Exception: # noqa: BLE001 - background builds must not crash workers logger.exception("Blacklist cloud build failed for user %s", user_id) finally: FollowCloud.objects.filter(user_id=user_id).update( building=False, building_since=None ) # Background threads hold their own DB connection; release it so # Gunicorn workers do not leak connections when threads finish. connection.close()