Keep e621 metadata on downloaded posts; clear up account roles

Roles:
- JakeBreathild is now staff + superuser (the real account); the 'jake'
  smoke-test account was demoted to a regular user
- /me exposes is_superuser and the account page shows an admin badge

e621 metadata:
- MediaItem gains e621_post_id and e621_data (trimmed post payload:
  tags by category, rating, score, favourites, comments, sources,
  description, pools, relationships, file info, uploader)
- Download to Library accepts the post payload from the SPA and stores
  it; the item's custom rating is seeded from the e621 rating when empty
- Library detail shows an e621 metadata card: link to the in-app post,
  score/favourites/comments, taxonomy-coloured tags, DText description,
  sources and pools; grid cards get an e621 badge and fall back to the
  e621 rating for their colour (display_rating)
- Guest visibility now also considers e621 tags, so downloaded explicit
  content is hidden from anonymous visitors
This commit is contained in:
2026-09-17 10:27:06 -05:00
parent e5cc63b0cc
commit ebac3ac922
13 changed files with 343 additions and 29 deletions
+1
View File
@@ -15,6 +15,7 @@ class UserSerializer(serializers.ModelSerializer):
"username",
"email",
"is_staff",
"is_superuser",
"date_joined",
"role",
"can_upload",
+22 -12
View File
@@ -48,9 +48,20 @@ def guest_blacklist_patterns():
return parse_blacklist_patterns(cached_guest_blacklist())
def item_hidden_from_guests(tags):
if not tags:
return False
def item_tag_names(item):
"""Custom tags plus every e621 tag category stored on the item."""
names = [str(tag) for tag in (item.tags or [])]
data = item.e621_data or {}
if isinstance(data, dict):
categories = data.get("tags") or {}
if isinstance(categories, dict):
for values in categories.values():
if isinstance(values, list):
names.extend(str(tag) for tag in values)
return names
def tags_hidden_from_guests(tags):
patterns = guest_blacklist_patterns()
if not patterns:
return False
@@ -63,6 +74,10 @@ def item_hidden_from_guests(tags):
return False
def item_is_hidden_for_guests(item):
return tags_hidden_from_guests(item_tag_names(item))
def fetch_e621_default_blacklist():
"""e621's anonymous default blacklist, or None when unreachable."""
try:
@@ -91,15 +106,10 @@ def refresh_guest_blacklist():
patterns = parse_blacklist_patterns(lines)
updates = []
for item in MediaItem.objects.only("id", "tags", "hidden_from_guests").iterator():
hidden = False
for tag in item.tags or []:
candidate = str(tag).strip().lower()
if candidate and any(
fnmatch(candidate, pattern) for pattern in patterns
):
hidden = True
break
for item in MediaItem.objects.only(
"id", "tags", "e621_data", "hidden_from_guests"
).iterator():
hidden = item_is_hidden_for_guests(item)
if item.hidden_from_guests != hidden:
item.hidden_from_guests = hidden
updates.append(item)
@@ -0,0 +1,23 @@
# Generated by Django 6.1.1 on 2026-09-17 15:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0002_mediaitem_hidden_from_guests_mediaitem_uploaded_by'),
]
operations = [
migrations.AddField(
model_name='mediaitem',
name='e621_data',
field=models.JSONField(blank=True, null=True),
),
migrations.AddField(
model_name='mediaitem',
name='e621_post_id',
field=models.IntegerField(blank=True, db_index=True, null=True),
),
]
+7 -2
View File
@@ -20,6 +20,8 @@ class MediaItem(models.Model):
)
# Guests never see items whose tags hit the mirrored default blacklist.
hidden_from_guests = models.BooleanField(default=False, db_index=True)
e621_post_id = models.IntegerField(null=True, blank=True, db_index=True)
e621_data = models.JSONField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -30,9 +32,12 @@ class MediaItem(models.Model):
return f"J-{self.pk} ({self.md5})"
def save(self, *args, **kwargs):
from .guest_filter import item_hidden_from_guests
from .guest_filter import item_is_hidden_for_guests
self.hidden_from_guests = item_hidden_from_guests(self.tags)
self.hidden_from_guests = item_is_hidden_for_guests(self)
update_fields = kwargs.get("update_fields")
if update_fields is not None:
kwargs["update_fields"] = set(update_fields) | {"hidden_from_guests"}
super().save(*args, **kwargs)
+14
View File
@@ -19,6 +19,7 @@ class MediaItemSerializer(serializers.ModelSerializer):
rating = serializers.ChoiceField(choices=["", "s", "q", "e"], required=False)
locations = MediaLocationSerializer(many=True, read_only=True)
j_id = serializers.SerializerMethodField()
display_rating = serializers.SerializerMethodField()
filename = serializers.SerializerMethodField()
extension = serializers.SerializerMethodField()
kind = serializers.SerializerMethodField()
@@ -33,6 +34,7 @@ class MediaItemSerializer(serializers.ModelSerializer):
"md5",
"size",
"rating",
"display_rating",
"tags",
"notes",
"filename",
@@ -40,6 +42,8 @@ class MediaItemSerializer(serializers.ModelSerializer):
"kind",
"uploaded_by",
"uploaded_by_id",
"e621_post_id",
"e621_data",
"locations",
"created_at",
"updated_at",
@@ -49,11 +53,14 @@ class MediaItemSerializer(serializers.ModelSerializer):
"j_id",
"md5",
"size",
"display_rating",
"filename",
"extension",
"kind",
"uploaded_by",
"uploaded_by_id",
"e621_post_id",
"e621_data",
"locations",
"created_at",
"updated_at",
@@ -62,6 +69,13 @@ class MediaItemSerializer(serializers.ModelSerializer):
def get_j_id(self, obj):
return f"J-{obj.id}"
def get_display_rating(self, obj):
if obj.rating:
return obj.rating
data = obj.e621_data or {}
rating = data.get("rating") if isinstance(data, dict) else None
return rating if rating in {"s", "q", "e"} else ""
def get_uploaded_by(self, obj):
return obj.uploaded_by.username if obj.uploaded_by else None
+53
View File
@@ -173,6 +173,59 @@ def download_file(url, destination):
handle.write(chunk)
E621_DESCRIPTION_LIMIT = 20000
def trim_e621_post(post):
"""Keep a compact, size-bounded copy of an e621 post payload."""
if not isinstance(post, dict):
return None
file_data = post.get("file") or {}
relationships = post.get("relationships") or {}
score = post.get("score") or {}
categories = post.get("tags") or {}
return {
"id": post.get("id"),
"created_at": post.get("created_at"),
"rating": post.get("rating"),
"tags": {
str(category): [str(tag)[:200] for tag in tags][:500]
for category, tags in categories.items()
if isinstance(tags, list)
},
"score": {
"up": score.get("up"),
"down": score.get("down"),
"total": score.get("total"),
},
"fav_count": post.get("fav_count"),
"comment_count": post.get("comment_count"),
"sources": [
str(source)[:500]
for source in (post.get("sources") or [])
if source
][:20],
"description": str(post.get("description") or "")[:E621_DESCRIPTION_LIMIT],
"pools": [
int(pool)
for pool in (post.get("pools") or [])
if str(pool).isdigit()
][:50],
"relationships": {
"parent_id": relationships.get("parent_id"),
"has_children": relationships.get("has_children"),
},
"file": {
"md5": file_data.get("md5"),
"ext": file_data.get("ext"),
"size": file_data.get("size"),
"width": file_data.get("width"),
"height": file_data.get("height"),
},
"uploader_name": post.get("uploader_name"),
}
def generate_video_thumbnail(md5, path):
"""Extract a JPEG thumbnail from a video, cached under MEDIA_ROOT/thumbs."""
if not shutil.which("ffmpeg"):
+19 -1
View File
@@ -223,6 +223,7 @@ class DownloadToLibraryView(APIView):
url = str(request.data.get("url") or "").strip()
post_id = request.data.get("post_id")
filename = str(request.data.get("filename") or "").strip()
post_payload = request.data.get("post")
if not url.startswith(("http://", "https://")):
return Response(
{"detail": "A valid file URL is required."},
@@ -244,9 +245,26 @@ class DownloadToLibraryView(APIView):
{"detail": f"Download failed: {exc}"},
status=status.HTTP_400_BAD_REQUEST,
)
update_fields = []
if item.uploaded_by_id is None:
item.uploaded_by = request.user
item.save(update_fields=["uploaded_by", "updated_at"])
update_fields.append("uploaded_by")
trimmed = services.trim_e621_post(post_payload)
if trimmed is not None:
if trimmed.get("id") is not None:
item.e621_post_id = int(trimmed["id"])
elif post_id and str(post_id).isdigit():
item.e621_post_id = int(post_id)
item.e621_data = trimmed
update_fields += ["e621_post_id", "e621_data"]
if not item.rating and trimmed.get("rating") in {"s", "q", "e"}:
item.rating = trimmed["rating"]
update_fields.append("rating")
if update_fields:
item.save(update_fields=update_fields + ["updated_at"])
return Response(
MediaItemSerializer(item, context={"request": request}).data,
status=status.HTTP_201_CREATED,