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.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user