diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index 05f381e..89e30b4 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -15,6 +15,7 @@ class UserSerializer(serializers.ModelSerializer): "username", "email", "is_staff", + "is_superuser", "date_joined", "role", "can_upload", diff --git a/backend/apps/library/guest_filter.py b/backend/apps/library/guest_filter.py index 7bdef1b..633d500 100644 --- a/backend/apps/library/guest_filter.py +++ b/backend/apps/library/guest_filter.py @@ -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) diff --git a/backend/apps/library/migrations/0003_mediaitem_e621_data_mediaitem_e621_post_id.py b/backend/apps/library/migrations/0003_mediaitem_e621_data_mediaitem_e621_post_id.py new file mode 100644 index 0000000..87c13b5 --- /dev/null +++ b/backend/apps/library/migrations/0003_mediaitem_e621_data_mediaitem_e621_post_id.py @@ -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), + ), + ] diff --git a/backend/apps/library/models.py b/backend/apps/library/models.py index 287469a..392aad6 100644 --- a/backend/apps/library/models.py +++ b/backend/apps/library/models.py @@ -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) diff --git a/backend/apps/library/serializers.py b/backend/apps/library/serializers.py index f9cff4d..6a6a989 100644 --- a/backend/apps/library/serializers.py +++ b/backend/apps/library/serializers.py @@ -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 diff --git a/backend/apps/library/services.py b/backend/apps/library/services.py index 13c469f..126bd7d 100644 --- a/backend/apps/library/services.py +++ b/backend/apps/library/services.py @@ -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"): diff --git a/backend/apps/library/views.py b/backend/apps/library/views.py index f2d9862..d77c426 100644 --- a/backend/apps/library/views.py +++ b/backend/apps/library/views.py @@ -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, diff --git a/frontend/src/components/MediaCard.tsx b/frontend/src/components/MediaCard.tsx index dd07514..a4825e9 100644 --- a/frontend/src/components/MediaCard.tsx +++ b/frontend/src/components/MediaCard.tsx @@ -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 ( - {item.rating ? ( + {rating ? ( - {ratingLabels[item.rating]} + {ratingLabels[rating]} + + ) : null} + {item.e621_post_id ? ( + + e621 ) : null} diff --git a/frontend/src/components/TagChip.tsx b/frontend/src/components/TagChip.tsx index 0866aa2..0f56746 100644 --- a/frontend/src/components/TagChip.tsx +++ b/frontend/src/components/TagChip.tsx @@ -33,17 +33,19 @@ export function TagChip({ onClick?: () => void; }) { const style = TAG_STYLES[category ?? "unknown"] ?? TAG_STYLES.unknown; + const className = cn( + "rounded border px-1.5 py-0.5 font-mono text-[11px] transition", + style, + ); + const label = count !== undefined ? `${tag} (${count})` : tag; + + if (!onClick) { + return {label}; + } + return ( - ); } diff --git a/frontend/src/features/account/AccountPage.tsx b/frontend/src/features/account/AccountPage.tsx index 2b22057..88c148a 100644 --- a/frontend/src/features/account/AccountPage.tsx +++ b/frontend/src/features/account/AccountPage.tsx @@ -230,11 +230,16 @@ export default function AccountPage() {

Account

{user ? ( -

+

Signed in as {user.username}. {user.role} + {user.is_superuser ? ( + + admin + + ) : null}

) : null}
diff --git a/frontend/src/features/library/LibraryDetail.tsx b/frontend/src/features/library/LibraryDetail.tsx index 99c217c..e3db68e 100644 --- a/frontend/src/features/library/LibraryDetail.tsx +++ b/frontend/src/features/library/LibraryDetail.tsx @@ -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 = { 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 ( +
+
+

+ e621 metadata +

+ + + in-app #{item.e621_post_id} + + + e621.net ↗ + + +
+ +
+
+
Rating
+
+ {data.rating ? RATING_LABELS[data.rating] ?? data.rating : "—"} +
+
+
+
Score
+
+ {data.score?.total ?? 0} +
+
+
+
Favorites
+
+ {data.fav_count ?? 0} +
+
+
+
Comments
+
+ {data.comment_count ?? 0} +
+
+
+ +
+ {categories.map(([category, tags]) => ( +
+

+ {TAG_CATEGORY_LABELS[category] ?? category} +

+
+ {tags.map((tag) => ( + + ))} +
+
+ ))} +
+ + {data.description.trim() ? ( +
+ + Description + +
+ {renderDText(data.description)} +
+
+ ) : null} + + {data.sources.length > 0 ? ( +
+

+ Sources +

+
    + {data.sources.slice(0, 5).map((source) => ( +
  • + + {source} + +
  • + ))} +
+
+ ) : null} + + {data.pools.length > 0 ? ( +
+ {data.pools.map((poolId) => ( + + Pool #{poolId} + + ))} +
+ ) : null} + + {data.uploader_name || data.file ? ( +

+ {data.uploader_name ? `uploaded by ${data.uploader_name}` : null} + {data.file?.width && data.file?.height + ? ` · ${data.file.width}×${data.file.height}` + : null} +

+ ) : null} +
+ ); +} + function CustomDataCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }) { const queryClient = useQueryClient(); const [rating, setRating] = useState(item.rating); @@ -324,6 +463,7 @@ export function LibraryDetail({ jId }: { jId: string }) { /> } + footer={} /> ); } diff --git a/frontend/src/features/online/OnlineDetail.tsx b/frontend/src/features/online/OnlineDetail.tsx index 25926ff..74dc7bb 100644 --- a/frontend/src/features/online/OnlineDetail.tsx +++ b/frontend/src/features/online/OnlineDetail.tsx @@ -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) => { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index d908aa9..4c31d09 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; + 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;