Files
JakeBreath 1adb761c8d Fix following past 48 entries and make the e621 page size configurable
Follow lists were paginated at the API default of 48, but the SPA treats
them as complete sets: the tag/pool toggles read their state from page one
(so the 49th follow looked unfollowed and its spinner waited for a page that
could never contain it) and the Followed page rendered only 48 cards while
showing that as the count. Both follow endpoints are now unpaginated — they
are per-user sets and still restricted to the caller's rows — and the three
consumers take plain arrays.

Post visibility: the old J621-Django online view fetched limit=320 (e621's
maximum) while ours hard-coded 48, and fetchPostsByIds capped id batches at
100. The Online browser now has a 'Posts per page' setting (48/100/200/320)
in its sidebar, mirrored in Account -> Browsing preferences, stored per user
as e621_per_page and also used for pool loading; the id-batch cap is raised
to 320.

Tests: follow list shape/isolation (4) and preference validation/merge (3)
added; the full backend suite is 46 green. Live-checked the array response
shape and the preference bounds (200 accepted, 500 rejected).
2026-09-18 19:06:18 -05:00

328 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.
Deliberately unpaginated: the SPA treats this as the complete set (follow
toggles read their state from it and the Followed page lists every card),
so a 48-item page silently broke following past 48 entries.
"""
serializer_class = FollowedTagSerializer
permission_classes = [IsAuthenticated]
pagination_class = None
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 (unpaginated, like the tag list)."""
serializer_class = FollowedPoolSerializer
permission_classes = [IsAuthenticated]
pagination_class = None
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):
force = request.query_params.get("refresh") == "1"
cloud, _ = FollowCloud.objects.get_or_create(user=request.user)
now = timezone.now()
stale = (
force
or 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,
}
)