From 03dd235f3a3a8185b47d56472b17a66f1b34fd85 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Thu, 17 Sep 2026 14:09:30 -0500 Subject: [PATCH] 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. --- ROADMAP.md | 16 +- backend/apps/follows/__init__.py | 0 backend/apps/follows/apps.py | 5 + backend/apps/follows/cloud.py | 97 +++ backend/apps/follows/management/__init__.py | 0 .../follows/management/commands/__init__.py | 0 .../commands/sync_followed_pools.py | 62 ++ .../management/commands/sync_followed_tags.py | 60 ++ .../apps/follows/migrations/0001_initial.py | 94 +++ backend/apps/follows/migrations/__init__.py | 0 backend/apps/follows/models.py | 114 ++++ backend/apps/follows/serializers.py | 53 ++ backend/apps/follows/sync.py | 171 +++++ backend/apps/follows/urls.py | 25 + backend/apps/follows/views.py | 317 +++++++++ backend/apps/library/e621.py | 10 +- backend/apps/library/services.py | 3 + backend/config/settings.py | 1 + backend/config/urls.py | 1 + frontend/src/App.tsx | 9 + frontend/src/components/AppShell.tsx | 1 + .../src/features/follows/FollowedPage.tsx | 637 ++++++++++++++++++ frontend/src/lib/types.ts | 71 ++ 23 files changed, 1741 insertions(+), 6 deletions(-) create mode 100644 backend/apps/follows/__init__.py create mode 100644 backend/apps/follows/apps.py create mode 100644 backend/apps/follows/cloud.py create mode 100644 backend/apps/follows/management/__init__.py create mode 100644 backend/apps/follows/management/commands/__init__.py create mode 100644 backend/apps/follows/management/commands/sync_followed_pools.py create mode 100644 backend/apps/follows/management/commands/sync_followed_tags.py create mode 100644 backend/apps/follows/migrations/0001_initial.py create mode 100644 backend/apps/follows/migrations/__init__.py create mode 100644 backend/apps/follows/models.py create mode 100644 backend/apps/follows/serializers.py create mode 100644 backend/apps/follows/sync.py create mode 100644 backend/apps/follows/urls.py create mode 100644 backend/apps/follows/views.py create mode 100644 frontend/src/features/follows/FollowedPage.tsx diff --git a/ROADMAP.md b/ROADMAP.md index 2683395..6701e73 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,9 +44,17 @@ they land. using the signed raw file) - [x] Show candidate posts to link (thumbnail, rating, score, favs, tags; select then confirm — same flow as uploads, exact MD5 marked) -- [ ] **Follows** - - [ ] Follow tags/pools, Followed screens with unseen badges - - [ ] "Followed" nav entry +- [x] **Follows** + - [x] Follow tags/pools from the `/followed` page (validated against e621, + first feed fetched immediately) + - [x] Followed screens: cover cards (latest image), unseen badges and + Mark seen per follow + - [x] Merged newest-first feed with per-follow filter and unseen-only toggle + - [x] Blacklisted-tag cloud built in a daemon thread, then polled + (10 min TTL; the user's e621 blacklist, guest default as fallback) + - [x] "Followed" nav entry + - [x] Periodic sync commands: `sync_followed_tags` and + `sync_followed_pools` (one e621 search per unique tag/pool) - [ ] `F` shortcut to favorite a post, tag finder in the Ctrl+K palette ## 3. Uploads & ingestion pipeline @@ -99,6 +107,8 @@ Files now stage first and are resolved before entering the library. ## 6. Infrastructure - [ ] Guest blacklist refresh on a timer (`refresh_guest_blacklist` via cron/systemd) +- [ ] Follow sync on a timer (`sync_followed_tags` + `sync_followed_pools` via + cron/systemd, e.g. every 30 minutes) - [ ] Production setup: build the SPA, serve via Nginx (static + `/media` + `/library`), systemd unit for Waitress - [ ] Automated tests (backend API + frontend components) diff --git a/backend/apps/follows/__init__.py b/backend/apps/follows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/follows/apps.py b/backend/apps/follows/apps.py new file mode 100644 index 0000000..f498291 --- /dev/null +++ b/backend/apps/follows/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class FollowsConfig(AppConfig): + name = "apps.follows" diff --git a/backend/apps/follows/cloud.py b/backend/apps/follows/cloud.py new file mode 100644 index 0000000..2cfad20 --- /dev/null +++ b/backend/apps/follows/cloud.py @@ -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() diff --git a/backend/apps/follows/management/__init__.py b/backend/apps/follows/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/follows/management/commands/__init__.py b/backend/apps/follows/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/follows/management/commands/sync_followed_pools.py b/backend/apps/follows/management/commands/sync_followed_pools.py new file mode 100644 index 0000000..5aef211 --- /dev/null +++ b/backend/apps/follows/management/commands/sync_followed_pools.py @@ -0,0 +1,62 @@ +from django.core.management.base import BaseCommand, CommandError + +from apps.follows import sync +from apps.follows.models import FollowedPool +from apps.library import e621 + + +class Command(BaseCommand): + help = ( + "Fetch new posts for every followed pool: fills each feed, refreshes " + "the card cover (latest image), pool name/count and unseen badges." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--user", + help="Only sync follows belonging to this username " + "(default: every user's follows).", + ) + parser.add_argument( + "--limit", + type=int, + default=sync.FEED_LIMIT, + help=f"Posts fetched per pool (default {sync.FEED_LIMIT}).", + ) + + def handle(self, *args, **options): + username = options.get("user") + if username and sync.preferred_fetch_user(username) is None: + raise CommandError(f"No such user: {username}") + fetch_user = sync.preferred_fetch_user(username) + + follows = FollowedPool.objects.select_related("user").order_by( + "pool_id", "user_id" + ) + if username: + follows = follows.filter(user__username=username) + follows = list(follows) + grouped = {} + for follow in follows: + grouped.setdefault(follow.pool_id, []).append(follow) + + created = 0 + for pool_id, group in grouped.items(): + try: + posts = sync.search_posts( + fetch_user, f"pool:{pool_id}", options["limit"] + ) + except e621.E621Error as exc: + raise CommandError(f"Aborted at pool #{pool_id}: {exc}") + for follow in group: + created += sync.sync_pool_follow( + follow, fetch_user, posts=posts + ) + + identity = getattr(fetch_user, "username", None) or "anonymous" + self.stdout.write( + self.style.SUCCESS( + f"Synced {len(grouped)} pool(s) across {len(follows)} follow(s) " + f"as {identity}: {created} new post(s)." + ) + ) diff --git a/backend/apps/follows/management/commands/sync_followed_tags.py b/backend/apps/follows/management/commands/sync_followed_tags.py new file mode 100644 index 0000000..e31d855 --- /dev/null +++ b/backend/apps/follows/management/commands/sync_followed_tags.py @@ -0,0 +1,60 @@ +from django.core.management.base import BaseCommand, CommandError + +from apps.follows import sync +from apps.follows.models import FollowedTag +from apps.library import e621 + + +class Command(BaseCommand): + help = ( + "Fetch new posts for every followed tag: fills each feed, refreshes " + "the card cover (latest image) and unseen badges." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--user", + help="Only sync follows belonging to this username " + "(default: every user's follows).", + ) + parser.add_argument( + "--limit", + type=int, + default=sync.FEED_LIMIT, + help=f"Posts fetched per tag (default {sync.FEED_LIMIT}).", + ) + + def handle(self, *args, **options): + username = options.get("user") + if username and sync.preferred_fetch_user(username) is None: + raise CommandError(f"No such user: {username}") + fetch_user = sync.preferred_fetch_user(username) + + follows = FollowedTag.objects.select_related("user").order_by( + "tag", "user_id" + ) + if username: + follows = follows.filter(user__username=username) + follows = list(follows) + grouped = {} + for follow in follows: + grouped.setdefault(follow.tag, []).append(follow) + + created = 0 + for tag, group in grouped.items(): + try: + posts = sync.search_posts(fetch_user, tag, options["limit"]) + except e621.E621Error as exc: + raise CommandError(f"Aborted at tag '{tag}': {exc}") + for follow in group: + created += sync.sync_tag_follow( + follow, fetch_user, posts=posts + ) + + identity = getattr(fetch_user, "username", None) or "anonymous" + self.stdout.write( + self.style.SUCCESS( + f"Synced {len(grouped)} tag(s) across {len(follows)} follow(s) " + f"as {identity}: {created} new post(s)." + ) + ) diff --git a/backend/apps/follows/migrations/0001_initial.py b/backend/apps/follows/migrations/0001_initial.py new file mode 100644 index 0000000..65178e9 --- /dev/null +++ b/backend/apps/follows/migrations/0001_initial.py @@ -0,0 +1,94 @@ +# Generated by Django 6.1.1 on 2026-09-17 19:04 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='FollowCloud', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('data', models.JSONField(blank=True, null=True)), + ('computed_at', models.DateTimeField(blank=True, null=True)), + ('building', models.BooleanField(default=False)), + ('building_since', models.DateTimeField(blank=True, null=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='follow_cloud', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='FollowedPool', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('pool_id', models.IntegerField()), + ('name', models.CharField(blank=True, default='', max_length=200)), + ('post_count', models.IntegerField(default=0)), + ('cover_data', models.JSONField(blank=True, null=True)), + ('last_synced_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='followed_pools', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['pool_id'], + }, + ), + migrations.CreateModel( + name='FollowedTag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tag', models.CharField(max_length=200)), + ('cover_data', models.JSONField(blank=True, null=True)), + ('last_synced_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='followed_tags', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['tag'], + }, + ), + migrations.CreateModel( + name='FollowedPost', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('post_id', models.IntegerField(db_index=True)), + ('data', models.JSONField(default=dict)), + ('seen', models.BooleanField(db_index=True, default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('pool', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='posts', to='follows.followedpool')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='followed_posts', to=settings.AUTH_USER_MODEL)), + ('tag', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='posts', to='follows.followedtag')), + ], + options={ + 'ordering': ['-post_id'], + }, + ), + migrations.AddConstraint( + model_name='followedpool', + constraint=models.UniqueConstraint(fields=('user', 'pool_id'), name='uniq_followed_pool_per_user'), + ), + migrations.AddConstraint( + model_name='followedtag', + constraint=models.UniqueConstraint(fields=('user', 'tag'), name='uniq_followed_tag_per_user'), + ), + migrations.AddIndex( + model_name='followedpost', + index=models.Index(fields=['user', 'post_id'], name='follows_fol_user_id_47fdab_idx'), + ), + migrations.AddConstraint( + model_name='followedpost', + constraint=models.UniqueConstraint(fields=('tag', 'post_id'), name='uniq_followed_tag_post'), + ), + migrations.AddConstraint( + model_name='followedpost', + constraint=models.UniqueConstraint(fields=('pool', 'post_id'), name='uniq_followed_pool_post'), + ), + ] diff --git a/backend/apps/follows/migrations/__init__.py b/backend/apps/follows/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/follows/models.py b/backend/apps/follows/models.py new file mode 100644 index 0000000..3a93112 --- /dev/null +++ b/backend/apps/follows/models.py @@ -0,0 +1,114 @@ +from django.conf import settings +from django.db import models + + +class FollowedTag(models.Model): + """A tag one user subscribes to; feeds are filled by the sync command.""" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="followed_tags", + ) + tag = models.CharField(max_length=200) + cover_data = models.JSONField(null=True, blank=True) + last_synced_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["tag"] + constraints = [ + models.UniqueConstraint( + fields=["user", "tag"], name="uniq_followed_tag_per_user" + ), + ] + + def __str__(self): + return f"{self.user} follows tag:{self.tag}" + + +class FollowedPool(models.Model): + """A pool one user subscribes to; feeds are filled by the sync command.""" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="followed_pools", + ) + pool_id = models.IntegerField() + name = models.CharField(max_length=200, blank=True, default="") + post_count = models.IntegerField(default=0) + cover_data = models.JSONField(null=True, blank=True) + last_synced_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["pool_id"] + constraints = [ + models.UniqueConstraint( + fields=["user", "pool_id"], name="uniq_followed_pool_per_user" + ), + ] + + def __str__(self): + return f"{self.user} follows pool:{self.pool_id}" + + +class FollowedPost(models.Model): + """One post seen in a followed feed (stored once per follow source).""" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="followed_posts", + ) + tag = models.ForeignKey( + FollowedTag, + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="posts", + ) + pool = models.ForeignKey( + FollowedPool, + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="posts", + ) + post_id = models.IntegerField(db_index=True) + data = models.JSONField(default=dict) + seen = models.BooleanField(default=False, db_index=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-post_id"] + indexes = [models.Index(fields=["user", "post_id"])] + constraints = [ + models.UniqueConstraint( + fields=["tag", "post_id"], name="uniq_followed_tag_post" + ), + models.UniqueConstraint( + fields=["pool", "post_id"], name="uniq_followed_pool_post" + ), + ] + + def __str__(self): + return f"#{self.post_id} for {self.user}" + + +class FollowCloud(models.Model): + """Cached blacklisted-tag cloud for the Followed page, built in a thread.""" + + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="follow_cloud", + ) + data = models.JSONField(null=True, blank=True) + computed_at = models.DateTimeField(null=True, blank=True) + building = models.BooleanField(default=False) + building_since = models.DateTimeField(null=True, blank=True) + + def __str__(self): + return f"Follow cloud for {self.user}" diff --git a/backend/apps/follows/serializers.py b/backend/apps/follows/serializers.py new file mode 100644 index 0000000..da0e612 --- /dev/null +++ b/backend/apps/follows/serializers.py @@ -0,0 +1,53 @@ +from rest_framework import serializers + +from .models import FollowedPool, FollowedTag + + +class FollowedTagSerializer(serializers.ModelSerializer): + unseen_count = serializers.IntegerField(read_only=True) + + class Meta: + model = FollowedTag + fields = [ + "id", + "tag", + "unseen_count", + "cover_data", + "last_synced_at", + "created_at", + ] + read_only_fields = [ + "id", + "tag", + "unseen_count", + "cover_data", + "last_synced_at", + "created_at", + ] + + +class FollowedPoolSerializer(serializers.ModelSerializer): + unseen_count = serializers.IntegerField(read_only=True) + + class Meta: + model = FollowedPool + fields = [ + "id", + "pool_id", + "name", + "post_count", + "unseen_count", + "cover_data", + "last_synced_at", + "created_at", + ] + read_only_fields = [ + "id", + "pool_id", + "name", + "post_count", + "unseen_count", + "cover_data", + "last_synced_at", + "created_at", + ] diff --git a/backend/apps/follows/sync.py b/backend/apps/follows/sync.py new file mode 100644 index 0000000..04c8375 --- /dev/null +++ b/backend/apps/follows/sync.py @@ -0,0 +1,171 @@ +"""Fetch and store followed tag/pool feeds from e621. + +Used by the two periodic management commands (`sync_followed_tags`, +`sync_followed_pools`) and by the follow API for the first fill of a new +subscription. One e621 search per unique tag/pool is shared across every +user following it. +""" + +import logging + +from django.contrib.auth import get_user_model +from django.utils import timezone + +from apps.library import e621 +from apps.library.services import trim_e621_post + +from .models import FollowedPool, FollowedPost, FollowedTag + +logger = logging.getLogger(__name__) + +FEED_LIMIT = 24 +PRUNE_KEEP = 200 + + +def preferred_fetch_user(username=None): + """A configured account for sync calls; None means run anonymously. + + e621 serves post searches and pool reads anonymously, so a missing + account is not fatal — credentials mainly raise rate limits. + """ + User = get_user_model() + if username: + return User.objects.filter(username=username).first() + return ( + User.objects.filter(is_staff=True) + .exclude(e621_username="") + .exclude(e621_api_key="") + .order_by("id") + .first() + ) + + +def search_posts(user, term, limit=FEED_LIMIT): + """Newest posts for a search term (``tag`` or ``pool:``).""" + payload = e621.get( + user, + "/posts.json", + params={"tags": term, "limit": limit}, + require_auth=False, + ) + posts = payload.get("posts") if isinstance(payload, dict) else None + return posts or [] + + +def cover_payload(post): + """Lightweight card cover: enough to render a thumbnail and rating.""" + if not isinstance(post, dict): + return None + preview = post.get("preview") or {} + score = post.get("score") or {} + return { + "post_id": post.get("id"), + "rating": post.get("rating"), + "preview_url": preview.get("url"), + "score": score.get("total"), + "fav_count": post.get("fav_count"), + } + + +def feed_payload(post): + """Compact stored post for the feed (tags included for the tag cloud).""" + trimmed = trim_e621_post(post) + if trimmed is None: + return None + return { + "id": trimmed.get("id"), + "created_at": trimmed.get("created_at"), + "rating": trimmed.get("rating"), + "tags": trimmed.get("tags") or {}, + "score": trimmed.get("score") or {}, + "fav_count": trimmed.get("fav_count"), + "file": trimmed.get("file") or {}, + "preview": trimmed.get("preview") or {}, + "uploader_name": trimmed.get("uploader_name"), + } + + +def _store_posts(follow, posts, tag=None, pool=None): + """Insert unseen rows for new posts. Returns how many were added.""" + existing = set(follow.posts.values_list("post_id", flat=True)) + rows = [] + for post in posts: + post_id = post.get("id") if isinstance(post, dict) else None + if not isinstance(post_id, int) or post_id in existing: + continue + data = feed_payload(post) + if data is None: + continue + rows.append( + FollowedPost( + user=follow.user, + post_id=post_id, + data=data, + tag=tag, + pool=pool, + ) + ) + if rows: + FollowedPost.objects.bulk_create(rows, ignore_conflicts=True) + _prune(follow) + return len(rows) + + +def _prune(follow): + """Keep only the newest PRUNE_KEEP posts per follow.""" + keep = list( + follow.posts.order_by("-post_id").values_list("id", flat=True)[ + :PRUNE_KEEP + ] + ) + follow.posts.exclude(id__in=keep).delete() + + +def sync_tag_follow(follow: FollowedTag, fetch_user=None, limit=FEED_LIMIT, posts=None): + """Fetch new posts for one followed tag; updates cover and sync time.""" + if posts is None: + posts = search_posts(fetch_user, follow.tag, limit) + added = _store_posts(follow, posts, tag=follow) + updates = ["last_synced_at"] + cover = cover_payload(posts[0]) if posts else None + if cover is not None: + follow.cover_data = cover + updates.append("cover_data") + follow.last_synced_at = timezone.now() + follow.save(update_fields=updates) + return added + + +def sync_pool_follow( + follow: FollowedPool, + fetch_user=None, + limit=FEED_LIMIT, + posts=None, + refresh_meta=True, +): + """Fetch new posts for one followed pool; updates cover and metadata.""" + if posts is None: + posts = search_posts(fetch_user, f"pool:{follow.pool_id}", limit) + added = _store_posts(follow, posts, pool=follow) + updates = ["last_synced_at"] + cover = cover_payload(posts[0]) if posts else None + if cover is not None: + follow.cover_data = cover + updates.append("cover_data") + if refresh_meta or not follow.name: + try: + pool = e621.get( + fetch_user, + f"/pools/{follow.pool_id}.json", + require_auth=False, + ) + except e621.E621Error as exc: + logger.warning("Could not refresh pool %s: %s", follow.pool_id, exc) + else: + if isinstance(pool, dict) and pool.get("id"): + follow.name = str(pool.get("name") or "")[:200] + follow.post_count = int(pool.get("post_count") or 0) + updates += ["name", "post_count"] + follow.last_synced_at = timezone.now() + follow.save(update_fields=updates) + return added diff --git a/backend/apps/follows/urls.py b/backend/apps/follows/urls.py new file mode 100644 index 0000000..04c0406 --- /dev/null +++ b/backend/apps/follows/urls.py @@ -0,0 +1,25 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import ( + FollowCloudView, + FollowedFeedView, + FollowedPoolViewSet, + FollowedPostSeenView, + FollowedTagViewSet, +) + +router = DefaultRouter() +router.register("follows/tags", FollowedTagViewSet, basename="followed-tag") +router.register("follows/pools", FollowedPoolViewSet, basename="followed-pool") + +urlpatterns = [ + path("", include(router.urls)), + path("follows/feed/", FollowedFeedView.as_view(), name="followed_feed"), + path("follows/cloud/", FollowCloudView.as_view(), name="followed_cloud"), + path( + "follows/posts//seen/", + FollowedPostSeenView.as_view(), + name="followed_post_seen", + ), +] diff --git a/backend/apps/follows/views.py b/backend/apps/follows/views.py new file mode 100644 index 0000000..72d274f --- /dev/null +++ b/backend/apps/follows/views.py @@ -0,0 +1,317 @@ +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, + } + ) diff --git a/backend/apps/library/e621.py b/backend/apps/library/e621.py index 4a2654a..e76e0ac 100644 --- a/backend/apps/library/e621.py +++ b/backend/apps/library/e621.py @@ -41,13 +41,17 @@ def _wait_for_slot(): _last_request_at = time.monotonic() -def get(user, path, params=None, timeout=30): +def get(user, path, params=None, timeout=30, require_auth=True): """GET an e621 API path using the user's credentials. + Reads that e621 serves anonymously (searches, pools, tags) can pass + require_auth=False; matching endpoints keep requiring credentials. + Raises E621NotFound for 404s and E621Error for everything else that isn't a 2xx, so callers never see requests exceptions. """ - if not credentials_configured(user): + configured = credentials_configured(user) + if require_auth and not configured: raise E621Error("Configure your e621 credentials in Account first.") base = (getattr(user, "e621_base_url", "") or "https://e621.net").rstrip("/") _wait_for_slot() @@ -55,7 +59,7 @@ def get(user, path, params=None, timeout=30): response = requests.get( f"{base}{path}", params=params, - auth=(user.e621_username, user.e621_api_key), + auth=(user.e621_username, user.e621_api_key) if configured else None, headers={"User-Agent": settings.USER_AGENT}, timeout=timeout, ) diff --git a/backend/apps/library/services.py b/backend/apps/library/services.py index d45b7b1..05a5381 100644 --- a/backend/apps/library/services.py +++ b/backend/apps/library/services.py @@ -323,6 +323,9 @@ def trim_e621_post(post): "height": file_data.get("height"), "url": file_data.get("url"), }, + "preview": { + "url": (post.get("preview") or {}).get("url"), + }, "uploader_name": post.get("uploader_name"), } diff --git a/backend/config/settings.py b/backend/config/settings.py index 261c0b6..9eb2cf0 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -52,6 +52,7 @@ INSTALLED_APPS = [ "django_filters", "apps.accounts", "apps.library", + "apps.follows", "apps.core", ] diff --git a/backend/config/urls.py b/backend/config/urls.py index b16204f..2a376cb 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -9,6 +9,7 @@ urlpatterns = [ path("api/", include("apps.accounts.api_urls")), path("api/", include("apps.core.urls")), path("api/", include("apps.library.urls")), + path("api/", include("apps.follows.urls")), ] if settings.DEBUG: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d8fc416..e27eb42 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import RegisterPage from "@/features/auth/RegisterPage"; import DetailPage from "@/features/detail/DetailPage"; import DeletePage from "@/features/library/DeletePage"; import DuplicatesPage from "@/features/library/DuplicatesPage"; +import FollowedPage from "@/features/follows/FollowedPage"; import LibraryPage from "@/features/library/LibraryPage"; import Md5Redirect from "@/features/library/Md5Redirect"; import OnlinePage from "@/features/online/OnlinePage"; @@ -70,6 +71,14 @@ export default function App() { } /> } /> } /> + + + + } + /> = { + s: "bg-ctp-green text-ctp-crust", + q: "bg-ctp-peach text-ctp-crust", + e: "bg-ctp-red text-ctp-crust", +}; + +function invalidateFollows(queryClient: ReturnType) { + void queryClient.invalidateQueries({ queryKey: ["follows-tags"] }); + void queryClient.invalidateQueries({ queryKey: ["follows-pools"] }); + void queryClient.invalidateQueries({ queryKey: ["follows-feed"] }); +} + +function unseenLabel(count: number): string { + return count > 99 ? "99+ unseen" : `${count} unseen`; +} + +function FollowForms() { + const queryClient = useQueryClient(); + const [tag, setTag] = useState(""); + const [pool, setPool] = useState(""); + const [error, setError] = useState(null); + + const tagMutation = useMutation({ + mutationFn: (value: string) => + api("/api/follows/tags/", { + method: "POST", + json: { tag: value }, + }), + onSuccess: () => { + setTag(""); + setError(null); + invalidateFollows(queryClient); + }, + onError: (err) => setError(errorMessage(err)), + }); + + const poolMutation = useMutation({ + mutationFn: (value: string) => + api("/api/follows/pools/", { + method: "POST", + json: { pool: value }, + }), + onSuccess: () => { + setPool(""); + setError(null); + invalidateFollows(queryClient); + }, + onError: (err) => setError(errorMessage(err)), + }); + + function submitTag(event: FormEvent) { + event.preventDefault(); + const value = tag.trim().toLowerCase(); + if (value) tagMutation.mutate(value); + } + + function submitPool(event: FormEvent) { + event.preventDefault(); + const value = pool.trim(); + if (value) poolMutation.mutate(value); + } + + return ( +
+
+ + Follow a tag + + setTag(event.target.value)} + /> + +
+ +
+ + Follow a pool + + setPool(event.target.value)} + /> + +
+ + {error ?

{error}

: null} +
+ ); +} + +function FollowCard({ + title, + meta, + cover, + unseen, + syncedAt, + selected, + onSelect, + onSeen, + onUnfollow, +}: { + title: string; + meta: string; + cover: FollowCoverData | null; + unseen: number; + syncedAt: string | null; + selected: boolean; + onSelect: () => void; + onSeen: () => void; + onUnfollow: () => void; +}) { + const metaLine = [meta, syncedAt ? `synced ${formatDate(syncedAt)}` : null] + .filter(Boolean) + .join(" · "); + + return ( +
+ + +
+
+
+

+ {title} +

+ {metaLine ? ( +

+ {metaLine} +

+ ) : null} +
+ +
+ +
+ +
+
+
+ ); +} + +function BlacklistCloudPanel() { + const query = useQuery({ + queryKey: ["follows-cloud"], + queryFn: () => api("/api/follows/cloud/"), + refetchInterval: (q) => + q.state.data?.status === "building" ? 2000 : false, + }); + const cloud = query.data; + + return ( +
+
+

+ Blacklisted tags +

+ {cloud?.status === "building" ? ( + + building… + + ) : null} +
+ +

+ {cloud?.source === "user" + ? "From your e621 blacklist" + : "From e621's anonymous default blacklist"} + {cloud ? ` · ${cloud.blacklist_count} entries` : ""} + {cloud ? ` · ${cloud.posts} feed post(s) scanned` : ""} +

+ + {query.isPending ? ( +
+ +
+ ) : cloud && cloud.tags.length > 0 ? ( +
+ {cloud.tags.map(([tag, count]) => ( + + {tag} ({count}) + + ))} +
+ ) : ( +

+ No blacklisted tags in your feeds. +

+ )} + + {cloud?.computed_at ? ( +

+ computed {formatDate(cloud.computed_at)} +

+ ) : null} +
+ ); +} + +export default function FollowedPage() { + const queryClient = useQueryClient(); + const [feedSource, setFeedSource] = useState<{ + kind: "all" | "tag" | "pool"; + id?: number; + label?: string; + }>({ kind: "all" }); + const [unseenOnly, setUnseenOnly] = useState(false); + const [page, setPage] = useState(1); + + const tagsQuery = useQuery({ + queryKey: ["follows-tags"], + queryFn: () => api>("/api/follows/tags/"), + }); + const poolsQuery = useQuery({ + queryKey: ["follows-pools"], + queryFn: () => api>("/api/follows/pools/"), + }); + + const feedQuery = useQuery({ + queryKey: [ + "follows-feed", + feedSource.kind, + feedSource.id ?? null, + unseenOnly, + page, + ], + queryFn: () => { + const params = new URLSearchParams({ + page: String(page), + per_page: "48", + }); + if (feedSource.kind === "tag") params.set("tag", String(feedSource.id)); + if (feedSource.kind === "pool") params.set("pool", String(feedSource.id)); + if (unseenOnly) params.set("unseen", "1"); + return api(`/api/follows/feed/?${params.toString()}`); + }, + }); + + const seenFollow = useMutation({ + mutationFn: ({ kind, id }: { kind: "tag" | "pool"; id: number }) => + api(`/api/follows/${kind === "tag" ? "tags" : "pools"}/${id}/seen/`, { + method: "POST", + }), + onSuccess: () => invalidateFollows(queryClient), + }); + + const seenPost = useMutation({ + mutationFn: (postId: number) => + api(`/api/follows/posts/${postId}/seen/`, { method: "POST" }), + onSuccess: () => invalidateFollows(queryClient), + }); + + const unfollow = useMutation({ + mutationFn: ({ kind, id }: { kind: "tag" | "pool"; id: number }) => + api(`/api/follows/${kind === "tag" ? "tags" : "pools"}/${id}/`, { + method: "DELETE", + }), + onSuccess: () => invalidateFollows(queryClient), + }); + + function selectSource(source: { + kind: "all" | "tag" | "pool"; + id?: number; + label?: string; + }) { + setPage(1); + setFeedSource((current) => + current.kind === source.kind && current.id === source.id + ? { kind: "all" } + : source, + ); + } + + const tags = tagsQuery.data?.results ?? []; + const pools = poolsQuery.data?.results ?? []; + const feed = feedQuery.data; + + return ( +
+
+

Followed

+

+ New posts from your followed e621 tags and pools. The periodic + commands refresh these feeds and their covers. +

+
+ + + +
+
+
+

+ Tags{" "} + + {tags.length} + +

+ {tagsQuery.isPending ? ( +
+ +
+ ) : tags.length === 0 ? ( +

+ No followed tags yet — follow one above. +

+ ) : ( +
+ {tags.map((follow) => ( + + selectSource({ + kind: "tag", + id: follow.id, + label: `tag:${follow.tag}`, + }) + } + onSeen={() => + seenFollow.mutate({ kind: "tag", id: follow.id }) + } + onUnfollow={() => + unfollow.mutate({ kind: "tag", id: follow.id }) + } + /> + ))} +
+ )} +
+ +
+

+ Pools{" "} + + {pools.length} + +

+ {poolsQuery.isPending ? ( +
+ +
+ ) : pools.length === 0 ? ( +

+ No followed pools yet — paste a pool ID or URL above. +

+ ) : ( +
+ {pools.map((follow) => ( + + selectSource({ + kind: "pool", + id: follow.id, + label: follow.name || `Pool #${follow.pool_id}`, + }) + } + onSeen={() => + seenFollow.mutate({ kind: "pool", id: follow.id }) + } + onUnfollow={() => + unfollow.mutate({ kind: "pool", id: follow.id }) + } + /> + ))} +
+ )} +
+ +
+
+

+ Feed{" "} + + {feed ? `${feed.count}` : "…"} + {feedSource.kind !== "all" + ? ` · showing ${feedSource.label}` + : ""} + +

+
+ + {feedSource.kind !== "all" ? ( + + ) : null} +
+
+ + {feedQuery.isPending ? ( +
+ +
+ ) : !feed || feed.results.length === 0 ? ( + + ) : ( +
+ {feed.results.map((entry) => ( + { + if (!entry.seen) seenPost.mutate(entry.post_id); + }} + className="group overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base transition hover:border-ctp-surface2" + > + + {entry.data.preview?.url ? ( + {`Post + ) : ( + + No preview + + )} + {!entry.seen ? ( + + ) : null} + {entry.data.rating ? ( + + {entry.data.rating} + + ) : null} + + + + #{entry.post_id} + + {" "} + ▲ {entry.data.score?.total ?? 0} + {entry.data.fav_count + ? ` · ${entry.data.fav_count} ★` + : ""} + + + + {entry.sources.map((source) => source.label).join(", ")} + + + + ))} +
+ )} + + {feed && (page > 1 || feed.has_next) ? ( +
+ + + Page {page} + + +
+ ) : null} +
+
+ + +
+
+ ); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 65be7c4..31f562a 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -204,6 +204,77 @@ export interface MatchTask { updated_at: string; } +export interface FollowCoverData { + post_id: number | null; + rating: string | null; + preview_url: string | null; + score: number | null; + fav_count: number | null; +} + +export interface FollowedTag { + id: number; + tag: string; + unseen_count: number; + cover_data: FollowCoverData | null; + last_synced_at: string | null; + created_at: string; +} + +export interface FollowedPool { + id: number; + pool_id: number; + name: string; + post_count: number; + unseen_count: number; + cover_data: FollowCoverData | null; + last_synced_at: string | null; + created_at: string; +} + +export interface FollowFeedSource { + kind: "tag" | "pool"; + id: number; + label: string; +} + +export interface FollowFeedEntry { + post_id: number; + seen: boolean; + data: { + id?: number | null; + rating?: string | null; + tags?: Record; + score?: { total?: number | null } | null; + fav_count?: number | null; + file?: { + width?: number | null; + height?: number | null; + ext?: string | null; + } | null; + preview?: { url?: string | null } | null; + }; + sources: FollowFeedSource[]; +} + +export interface FollowFeed { + count: number; + page: number; + per_page: number; + has_next: boolean; + results: FollowFeedEntry[]; +} + +export interface FollowCloud { + status: "ready" | "building"; + stale: boolean; + tags: [string, number][]; + posts: number; + source: "user" | "default" | null; + blacklist_count: number; + computed_at: string | null; +} + export interface E621IqdbCandidate { post_id: number | null; score: number | null;