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,
+12 -3
View File
@@ -21,6 +21,7 @@ export function MediaCard({ item }: { item: MediaItem }) {
item.kind === "video"
? `/api/files/${item.j_id}/thumbnail/`
: `/api/files/${item.j_id}/raw/`;
const rating = item.display_rating;
return (
<Link
@@ -34,14 +35,22 @@ export function MediaCard({ item }: { item: MediaItem }) {
loading="lazy"
className="h-full w-full object-cover transition duration-200 group-hover:scale-[1.03]"
/>
{item.rating ? (
{rating ? (
<span
className={cn(
"absolute left-2 top-2 rounded-full border px-2 py-0.5 font-mono text-[11px] font-medium",
ratingStyles[item.rating],
ratingStyles[rating],
)}
>
{ratingLabels[item.rating]}
{ratingLabels[rating]}
</span>
) : null}
{item.e621_post_id ? (
<span
className="absolute left-2 top-8 rounded-sm bg-ctp-lavender/90 px-1.5 py-0.5 font-mono text-[10px] font-medium text-ctp-crust"
title={`e621 post #${item.e621_post_id}`}
>
e621
</span>
) : null}
<span className="absolute right-2 top-2 rounded-sm bg-ctp-crust/80 px-1.5 py-0.5 font-mono text-[10px] uppercase text-ctp-overlay1">
+11 -9
View File
@@ -33,17 +33,19 @@ export function TagChip({
onClick?: () => void;
}) {
const style = TAG_STYLES[category ?? "unknown"] ?? TAG_STYLES.unknown;
return (
<button
type="button"
onClick={onClick}
className={cn(
const className = cn(
"rounded border px-1.5 py-0.5 font-mono text-[11px] transition",
style,
)}
>
{tag}
{count !== undefined ? ` (${count})` : ""}
);
const label = count !== undefined ? `${tag} (${count})` : tag;
if (!onClick) {
return <span className={className}>{label}</span>;
}
return (
<button type="button" onClick={onClick} className={className}>
{label}
</button>
);
}
@@ -230,11 +230,16 @@ export default function AccountPage() {
<header>
<h1 className="text-lg font-semibold">Account</h1>
{user ? (
<p className="mt-1 flex items-center gap-2 text-sm text-ctp-overlay0">
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-ctp-overlay0">
<span>Signed in as {user.username}.</span>
<span className="rounded-full border border-ctp-surface1 px-2 py-0.5 font-mono text-[11px] capitalize text-ctp-subtext0">
{user.role}
</span>
{user.is_superuser ? (
<span className="rounded-full border border-ctp-red/40 bg-ctp-red/10 px-2 py-0.5 font-mono text-[11px] text-ctp-red">
admin
</span>
) : null}
</p>
) : null}
</header>
@@ -4,10 +4,13 @@ import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Button, EmptyState, Spinner, inputClass, linkButtonClass } from "@/components/ui";
import { TagChip } from "@/components/TagChip";
import { DetailShell } from "@/features/detail/DetailShell";
import { api, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import { isTypingTarget } from "@/lib/dom";
import { renderDText } from "@/lib/dtext";
import { RATING_LABELS, TAG_CATEGORY_LABELS } from "@/lib/e621";
import { formatBytes, formatDate } from "@/lib/format";
import type { FileNeighbors, MediaItem, Rating } from "@/lib/types";
import { useAuth } from "@/store/auth";
@@ -42,6 +45,142 @@ const ratingLabel: Record<string, string> = {
e: "Explicit",
};
function E621MetadataCard({ item }: { item: MediaItem }) {
const data = item.e621_data;
if (!item.e621_post_id || !data) return null;
const categories = Object.entries(data.tags).filter(
([, tags]) => tags.length > 0,
);
return (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-ctp-subtext1">
e621 metadata
</h2>
<span className="flex items-center gap-3">
<Link
to={`/detail/${item.e621_post_id}`}
className="font-mono text-xs text-ctp-blue hover:underline"
>
in-app #{item.e621_post_id}
</Link>
<a
href={`https://e621.net/posts/${item.e621_post_id}`}
target="_blank"
rel="noreferrer"
className="font-mono text-xs text-ctp-overlay0 hover:underline"
>
e621.net ↗
</a>
</span>
</div>
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-4">
<div>
<dt className="text-ctp-overlay0">Rating</dt>
<dd className="text-ctp-subtext0">
{data.rating ? RATING_LABELS[data.rating] ?? data.rating : "—"}
</dd>
</div>
<div>
<dt className="text-ctp-overlay0">Score</dt>
<dd className="font-mono text-ctp-subtext0">
{data.score?.total ?? 0}
</dd>
</div>
<div>
<dt className="text-ctp-overlay0">Favorites</dt>
<dd className="font-mono text-ctp-subtext0">
{data.fav_count ?? 0}
</dd>
</div>
<div>
<dt className="text-ctp-overlay0">Comments</dt>
<dd className="font-mono text-ctp-subtext0">
{data.comment_count ?? 0}
</dd>
</div>
</dl>
<div className="mt-3 flex flex-col gap-3">
{categories.map(([category, tags]) => (
<div key={category}>
<p className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-ctp-overlay0">
{TAG_CATEGORY_LABELS[category] ?? category}
</p>
<div className="flex flex-wrap gap-1.5">
{tags.map((tag) => (
<TagChip key={tag} tag={tag} category={category} />
))}
</div>
</div>
))}
</div>
{data.description.trim() ? (
<details className="mt-3">
<summary className="cursor-pointer text-sm font-semibold text-ctp-subtext1">
Description
</summary>
<div className="mt-2 text-sm leading-relaxed text-ctp-subtext0">
{renderDText(data.description)}
</div>
</details>
) : null}
{data.sources.length > 0 ? (
<div className="mt-3">
<p className="text-[11px] font-medium uppercase tracking-wide text-ctp-overlay0">
Sources
</p>
<ul className="mt-1 flex flex-col gap-1">
{data.sources.slice(0, 5).map((source) => (
<li key={source} className="truncate">
<a
href={source}
target="_blank"
rel="noreferrer"
className="font-mono text-xs text-ctp-blue hover:underline"
title={source}
>
{source}
</a>
</li>
))}
</ul>
</div>
) : null}
{data.pools.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{data.pools.map((poolId) => (
<a
key={poolId}
href={`https://e621.net/pools/${poolId}`}
target="_blank"
rel="noreferrer"
className="rounded border border-ctp-surface1 px-1.5 py-0.5 font-mono text-[11px] text-ctp-subtext0 transition hover:bg-ctp-surface0"
>
Pool #{poolId}
</a>
))}
</div>
) : null}
{data.uploader_name || data.file ? (
<p className="mt-3 font-mono text-[11px] text-ctp-overlay0">
{data.uploader_name ? `uploaded by ${data.uploader_name}` : null}
{data.file?.width && data.file?.height
? ` · ${data.file.width}×${data.file.height}`
: null}
</p>
) : null}
</section>
);
}
function CustomDataCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }) {
const queryClient = useQueryClient();
const [rating, setRating] = useState<Rating>(item.rating);
@@ -324,6 +463,7 @@ export function LibraryDetail({ jId }: { jId: string }) {
/>
</>
}
footer={<E621MetadataCard item={item} />}
/>
);
}
@@ -124,6 +124,7 @@ export function OnlineDetail({ postId }: { postId: number }) {
post_id: target.id,
url: target.file.url,
filename: `${target.file.md5}.${target.file.ext}`,
post: target,
},
}),
onSuccess: (item) => {
+33
View File
@@ -3,6 +3,7 @@ export interface User {
username: string;
email: string;
is_staff: boolean;
is_superuser: boolean;
date_joined: string;
role: "user" | "uploader" | "staff";
can_upload: boolean;
@@ -19,12 +20,42 @@ export interface MediaLocation {
export type Rating = "" | "s" | "q" | "e";
export interface E621StoredPost {
id: number | null;
created_at?: string | null;
rating?: "s" | "q" | "e" | null;
tags: Record<string, string[]>;
score?: {
up?: number | null;
down?: number | null;
total?: number | null;
} | null;
fav_count?: number | null;
comment_count?: number | null;
sources: string[];
description: string;
pools: number[];
relationships?: {
parent_id?: number | null;
has_children?: boolean | null;
} | null;
file?: {
md5?: string | null;
ext?: string | null;
size?: number | null;
width?: number | null;
height?: number | null;
} | null;
uploader_name?: string | null;
}
export interface MediaItem {
id: number;
j_id: string;
md5: string;
size: number;
rating: Rating;
display_rating: Rating;
tags: string[];
notes: string;
filename: string;
@@ -32,6 +63,8 @@ export interface MediaItem {
kind: "image" | "video";
uploaded_by: string | null;
uploaded_by_id: number | null;
e621_post_id: number | null;
e621_data: E621StoredPost | null;
locations: MediaLocation[];
created_at: string;
updated_at: string;