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.
318 lines
11 KiB
Python
318 lines
11 KiB
Python
import re
|
|
|
|
from django.db.models import Count, Q
|
|
from django.utils import timezone
|
|
from rest_framework import mixins, status, viewsets
|
|
from rest_framework.decorators import action
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
|
|
from apps.library import e621
|
|
|
|
from . import cloud as cloud_service
|
|
from . import sync
|
|
from .models import FollowCloud, FollowedPool, FollowedPost, FollowedTag
|
|
from .serializers import FollowedPoolSerializer, FollowedTagSerializer
|
|
|
|
POOL_URL_RE = re.compile(r"/pools/(\d+)")
|
|
|
|
|
|
def _parse_page(request, default_per_page=48):
|
|
try:
|
|
per_page = int(request.query_params.get("per_page", default_per_page))
|
|
except ValueError:
|
|
per_page = default_per_page
|
|
try:
|
|
page = int(request.query_params.get("page", 1))
|
|
except ValueError:
|
|
page = 1
|
|
return max(1, page), min(200, max(1, per_page))
|
|
|
|
|
|
class FollowedTagViewSet(
|
|
mixins.ListModelMixin,
|
|
mixins.CreateModelMixin,
|
|
mixins.DestroyModelMixin,
|
|
viewsets.GenericViewSet,
|
|
):
|
|
"""Tags the current user follows."""
|
|
|
|
serializer_class = FollowedTagSerializer
|
|
permission_classes = [IsAuthenticated]
|
|
http_method_names = ["get", "post", "delete", "head", "options"]
|
|
|
|
def get_queryset(self):
|
|
return (
|
|
FollowedTag.objects.filter(user=self.request.user)
|
|
.annotate(
|
|
unseen_count=Count("posts", filter=Q(posts__seen=False), distinct=True)
|
|
)
|
|
.order_by("tag")
|
|
)
|
|
|
|
def create(self, request):
|
|
tag = str(request.data.get("tag") or "").strip().lower()[:200]
|
|
if not tag:
|
|
return Response(
|
|
{"detail": "A tag is required."}, status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
if FollowedTag.objects.filter(user=request.user, tag=tag).exists():
|
|
return Response(
|
|
{"detail": f"Already following '{tag}'."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
try:
|
|
results = e621.get(
|
|
request.user,
|
|
"/tags.json",
|
|
params={"search[name_matches]": tag, "limit": 5},
|
|
require_auth=False,
|
|
)
|
|
except e621.E621Error as exc:
|
|
return Response(
|
|
{"detail": str(exc)}, status=status.HTTP_502_BAD_GATEWAY
|
|
)
|
|
names = {
|
|
str(entry.get("name", "")).lower()
|
|
for entry in results
|
|
if isinstance(entry, dict)
|
|
}
|
|
if tag not in names:
|
|
return Response(
|
|
{"detail": f"e621 has no tag named '{tag}'."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
follow = FollowedTag.objects.create(user=request.user, tag=tag)
|
|
FollowCloud.objects.filter(user=request.user).update(computed_at=None)
|
|
try:
|
|
sync.sync_tag_follow(follow, request.user)
|
|
except e621.E621Error:
|
|
# The periodic command will catch up; the follow still stands.
|
|
pass
|
|
follow = self.get_queryset().get(pk=follow.pk)
|
|
return Response(
|
|
self.get_serializer(follow).data, status=status.HTTP_201_CREATED
|
|
)
|
|
|
|
def perform_destroy(self, instance):
|
|
instance.delete()
|
|
FollowCloud.objects.filter(user=self.request.user).update(computed_at=None)
|
|
|
|
@action(detail=True, methods=["post"])
|
|
def seen(self, request, pk=None):
|
|
follow = self.get_object()
|
|
follow.posts.update(seen=True)
|
|
return Response({"success": True})
|
|
|
|
|
|
class FollowedPoolViewSet(
|
|
mixins.ListModelMixin,
|
|
mixins.CreateModelMixin,
|
|
mixins.DestroyModelMixin,
|
|
viewsets.GenericViewSet,
|
|
):
|
|
"""Pools the current user follows."""
|
|
|
|
serializer_class = FollowedPoolSerializer
|
|
permission_classes = [IsAuthenticated]
|
|
http_method_names = ["get", "post", "delete", "head", "options"]
|
|
|
|
def get_queryset(self):
|
|
return (
|
|
FollowedPool.objects.filter(user=self.request.user)
|
|
.annotate(
|
|
unseen_count=Count("posts", filter=Q(posts__seen=False), distinct=True)
|
|
)
|
|
.order_by("pool_id")
|
|
)
|
|
|
|
def create(self, request):
|
|
raw = str(
|
|
request.data.get("pool") or request.data.get("pool_id") or ""
|
|
).strip()
|
|
match = POOL_URL_RE.search(raw)
|
|
if match:
|
|
pool_id = int(match.group(1))
|
|
elif raw.isdigit():
|
|
pool_id = int(raw)
|
|
else:
|
|
return Response(
|
|
{"detail": "Enter an e621 pool ID or pool URL."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
if FollowedPool.objects.filter(user=request.user, pool_id=pool_id).exists():
|
|
return Response(
|
|
{"detail": f"Already following pool #{pool_id}."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
try:
|
|
pool = e621.get(
|
|
request.user, f"/pools/{pool_id}.json", require_auth=False
|
|
)
|
|
except e621.E621NotFound:
|
|
return Response(
|
|
{"detail": f"e621 has no pool #{pool_id}."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
except e621.E621Error as exc:
|
|
return Response(
|
|
{"detail": str(exc)}, status=status.HTTP_502_BAD_GATEWAY
|
|
)
|
|
follow = FollowedPool.objects.create(
|
|
user=request.user,
|
|
pool_id=pool_id,
|
|
name=str(pool.get("name") or "")[:200],
|
|
post_count=int(pool.get("post_count") or 0),
|
|
)
|
|
FollowCloud.objects.filter(user=request.user).update(computed_at=None)
|
|
try:
|
|
sync.sync_pool_follow(follow, request.user, refresh_meta=False)
|
|
except e621.E621Error:
|
|
pass
|
|
follow = self.get_queryset().get(pk=follow.pk)
|
|
return Response(
|
|
self.get_serializer(follow).data, status=status.HTTP_201_CREATED
|
|
)
|
|
|
|
def perform_destroy(self, instance):
|
|
instance.delete()
|
|
FollowCloud.objects.filter(user=self.request.user).update(computed_at=None)
|
|
|
|
@action(detail=True, methods=["post"])
|
|
def seen(self, request, pk=None):
|
|
follow = self.get_object()
|
|
follow.posts.update(seen=True)
|
|
return Response({"success": True})
|
|
|
|
|
|
class FollowedFeedView(APIView):
|
|
"""Merged newest-first feed across followed tags and pools."""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def get(self, request):
|
|
page, per_page = _parse_page(request)
|
|
queryset = FollowedPost.objects.filter(user=request.user).order_by(
|
|
"-post_id"
|
|
)
|
|
tag_id = request.query_params.get("tag", "")
|
|
pool_id = request.query_params.get("pool", "")
|
|
if tag_id.isdigit():
|
|
queryset = queryset.filter(tag_id=int(tag_id))
|
|
if pool_id.isdigit():
|
|
queryset = queryset.filter(pool_id=int(pool_id))
|
|
if request.query_params.get("unseen") == "1":
|
|
queryset = queryset.filter(seen=False)
|
|
|
|
tag_names = dict(
|
|
FollowedTag.objects.filter(user=request.user).values_list("id", "tag")
|
|
)
|
|
pool_names = dict(
|
|
FollowedPool.objects.filter(user=request.user).values_list(
|
|
"id", "name"
|
|
)
|
|
)
|
|
entries = {}
|
|
for post_id, seen, data, tag, pool in queryset.values_list(
|
|
"post_id", "seen", "data", "tag_id", "pool_id"
|
|
)[:2000]:
|
|
entry = entries.get(post_id)
|
|
if entry is None:
|
|
entry = entries[post_id] = {
|
|
"post_id": post_id,
|
|
"seen": seen,
|
|
"data": data,
|
|
"sources": [],
|
|
}
|
|
else:
|
|
entry["seen"] = entry["seen"] and seen
|
|
if tag and not any(
|
|
source["kind"] == "tag" and source["id"] == tag
|
|
for source in entry["sources"]
|
|
):
|
|
entry["sources"].append(
|
|
{
|
|
"kind": "tag",
|
|
"id": tag,
|
|
"label": tag_names.get(tag, f"tag:{tag}"),
|
|
}
|
|
)
|
|
if pool and not any(
|
|
source["kind"] == "pool" and source["id"] == pool
|
|
for source in entry["sources"]
|
|
):
|
|
entry["sources"].append(
|
|
{
|
|
"kind": "pool",
|
|
"id": pool,
|
|
"label": pool_names.get(pool) or f"Pool #{pool}",
|
|
}
|
|
)
|
|
|
|
items = list(entries.values())
|
|
count = len(items)
|
|
start = (page - 1) * per_page
|
|
return Response(
|
|
{
|
|
"count": count,
|
|
"page": page,
|
|
"per_page": per_page,
|
|
"has_next": start + per_page < count,
|
|
"results": items[start : start + per_page],
|
|
}
|
|
)
|
|
|
|
|
|
class FollowedPostSeenView(APIView):
|
|
"""Mark one post seen across every follow it appears in."""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def post(self, request, post_id):
|
|
FollowedPost.objects.filter(user=request.user, post_id=post_id).update(
|
|
seen=True
|
|
)
|
|
return Response({"success": True})
|
|
|
|
|
|
class FollowCloudView(APIView):
|
|
"""The blacklisted-tag cloud, rebuilt in a thread when stale."""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def get(self, request):
|
|
cloud, _ = FollowCloud.objects.get_or_create(user=request.user)
|
|
now = timezone.now()
|
|
stale = cloud.computed_at is None or now - cloud.computed_at > (
|
|
cloud_service.CLOUD_TTL
|
|
)
|
|
if stale:
|
|
reclaim = Q(building=False) | Q(
|
|
building_since__lt=now - cloud_service.BUILD_STALE
|
|
)
|
|
started = (
|
|
FollowCloud.objects.filter(pk=cloud.pk)
|
|
.filter(reclaim)
|
|
.update(building=True, building_since=now)
|
|
)
|
|
if started:
|
|
cloud_service.start_cloud_build(request.user.id)
|
|
cloud.refresh_from_db()
|
|
building = cloud.building and not (
|
|
cloud.building_since
|
|
and now - cloud.building_since > cloud_service.BUILD_STALE
|
|
)
|
|
data = cloud.data or {}
|
|
return Response(
|
|
{
|
|
"status": "building" if building else "ready",
|
|
"stale": stale,
|
|
"tags": data.get("tags", []),
|
|
"posts": data.get("posts", 0),
|
|
"source": data.get("source"),
|
|
"blacklist_count": data.get("blacklist_count", 0),
|
|
"computed_at": cloud.computed_at,
|
|
}
|
|
)
|