Files
J621/backend/apps/follows/cloud.py
T
JakeBreath 03dd235f3a Follows: followed tags/pools, feeds, unseen badges and blacklist cloud
Backend (new apps.follows):
- FollowedTag/FollowedPool/FollowedPost models; per-user follows with
  unseen tracking, plus FollowCloud for the cached blacklist cloud.
- Two periodic commands sharing one fetch path: sync_followed_tags and
  sync_followed_pools fetch each followed tag/pool's newest posts (one
  e621 search per unique follow), store unseen feed rows, refresh covers
  and pool metadata; both fall back to anonymous e621 access.
- API: /api/follows/tags|pools (follow, unfollow, mark seen), a merged
  feed with per-follow filtering, and /api/follows/cloud/ which rebuilds
  the blacklisted-tag cloud in a daemon thread when its 10 min cache is
  stale (polling returns building/ready).
- e621 client now supports anonymous reads; trimmed posts carry preview
  URLs for covers and feed tiles.

Frontend:
- /followed page: follow forms, cover cards with unseen badges and
  Mark seen, merged feed with filter/unseen toggle, and a blacklist
  cloud panel that polls while building. Followed nav entry added.
2026-09-17 14:09:30 -05:00

98 lines
3.4 KiB
Python

"""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()