Phase 3: J-IDs, ownership, roles, guest safety, adaptive detail, download

Backend:
- User.role (user/uploader/staff) with can_upload; uploads and downloads
  gated to uploader+; owners and staff can edit their items
- MediaItem.uploaded_by plus J-<id> identity (serializer, admin,
  scan_files --user, first superuser as default owner)
- API resolves J-<id>, bare numeric ids and MD5s; neighbors and lookup
  return j_ids
- Guest safety: mirror e621's anonymous default blacklist into Redis
  (parses comments, negations and wildcards), flag hidden_from_guests
  and filter lists, details and lookups for anonymous users
- POST /api/online/downloads/ writes an e621 file into the watched
  folder and indexes it for the uploader
- MariaDB + Redis via docker compose (host ports 3307/6380), PyMySQL
  driver shim, Redis cache replacing the file cache; SQLite data
  dumped and loaded into MariaDB

Frontend:
- Single /detail/:itemId route with an adaptive shell: J-<id> renders
  the library item, bare numbers render the e621 post
- Legacy /view/<md5> and /online/view/<id> redirect to canonical URLs
- Cards expose J-IDs; library custom-data editor is read-only for
  non-owners
- Role gating: Upload hidden/blocked for regular users, account shows
  the role, guest hint on the library
This commit is contained in:
2026-09-17 10:16:45 -05:00
parent d6c3f90c1f
commit e5cc63b0cc
36 changed files with 954 additions and 194 deletions
+15
View File
@@ -3,3 +3,18 @@ DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1 ALLOWED_HOSTS=localhost,127.0.0.1
# Absolute path to your media folder, or relative to the backend/ folder. # Absolute path to your media folder, or relative to the backend/ folder.
WATCHED_FOLDER=media/library WATCHED_FOLDER=media/library
# MariaDB (docker compose at the repo root)
DB_HOST=127.0.0.1
DB_PORT=3307
DB_NAME=j621
DB_USER=j621
DB_PASSWORD=j621
# Redis (docker compose at the repo root)
REDIS_URL=redis://127.0.0.1:6380/1
# Guest visibility: e621's anonymous default blacklist is mirrored into
# Redis by `manage.py refresh_guest_blacklist`.
# GUEST_BLACKLIST_FALLBACK=young,cub,shota,loli,child,underage
# GUEST_BLACKLIST_TTL=3600
+15 -1
View File
@@ -6,4 +6,18 @@ from .models import User
@admin.register(User) @admin.register(User)
class CustomUserAdmin(UserAdmin): class CustomUserAdmin(UserAdmin):
pass list_display = ("username", "email", "role", "is_staff", "is_superuser")
list_filter = UserAdmin.list_filter + ("role",)
fieldsets = UserAdmin.fieldsets + (
(
"J621",
{
"fields": (
"role",
"e621_username",
"e621_api_key",
"e621_base_url",
)
},
),
)
@@ -0,0 +1,18 @@
# Generated by Django 6.1.1 on 2026-09-17 15:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_user_e621_api_key_user_e621_base_url_and_more'),
]
operations = [
migrations.AddField(
model_name='user',
name='role',
field=models.CharField(choices=[('user', 'User'), ('uploader', 'Uploader'), ('staff', 'Staff')], default='user', max_length=20),
),
]
+15 -1
View File
@@ -3,8 +3,18 @@ from django.db import models
class User(AbstractUser): class User(AbstractUser):
"""Project user with optional e621 API credentials.""" """Project user with optional e621 API credentials and an app role."""
ROLE_USER = "user"
ROLE_UPLOADER = "uploader"
ROLE_STAFF = "staff"
ROLE_CHOICES = [
(ROLE_USER, "User"),
(ROLE_UPLOADER, "Uploader"),
(ROLE_STAFF, "Staff"),
]
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default=ROLE_USER)
e621_username = models.CharField(max_length=100, blank=True, default="") e621_username = models.CharField(max_length=100, blank=True, default="")
e621_api_key = models.CharField(max_length=100, blank=True, default="") e621_api_key = models.CharField(max_length=100, blank=True, default="")
e621_base_url = models.CharField(max_length=200, default="https://e621.net") e621_base_url = models.CharField(max_length=200, default="https://e621.net")
@@ -12,3 +22,7 @@ class User(AbstractUser):
@property @property
def e621_configured(self): def e621_configured(self):
return bool(self.e621_username and self.e621_api_key) return bool(self.e621_username and self.e621_api_key)
@property
def can_upload(self):
return self.is_superuser or self.role in {self.ROLE_UPLOADER, self.ROLE_STAFF}
+3
View File
@@ -6,6 +6,7 @@ from .models import User
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
e621_configured = serializers.BooleanField(read_only=True) e621_configured = serializers.BooleanField(read_only=True)
can_upload = serializers.BooleanField(read_only=True)
class Meta: class Meta:
model = User model = User
@@ -15,6 +16,8 @@ class UserSerializer(serializers.ModelSerializer):
"email", "email",
"is_staff", "is_staff",
"date_joined", "date_joined",
"role",
"can_upload",
"e621_username", "e621_username",
"e621_configured", "e621_configured",
] ]
+7 -2
View File
@@ -11,11 +11,16 @@ class MediaLocationInline(admin.TabularInline):
@admin.register(MediaItem) @admin.register(MediaItem)
class MediaItemAdmin(admin.ModelAdmin): class MediaItemAdmin(admin.ModelAdmin):
list_display = ("md5", "size", "rating", "created_at") list_display = ("__str__", "j_id", "size", "rating", "uploaded_by", "hidden_from_guests", "created_at")
list_filter = ("rating",) list_filter = ("rating", "hidden_from_guests")
search_fields = ("md5",) search_fields = ("md5",)
readonly_fields = ("hidden_from_guests",)
inlines = [MediaLocationInline] inlines = [MediaLocationInline]
@admin.display(description="J-ID")
def j_id(self, obj):
return f"J-{obj.id}"
@admin.register(MediaLocation) @admin.register(MediaLocation)
class MediaLocationAdmin(admin.ModelAdmin): class MediaLocationAdmin(admin.ModelAdmin):
+114
View File
@@ -0,0 +1,114 @@
"""Guest visibility: mirror e621's anonymous default blacklist.
Items whose tags intersect the blacklist are hidden from anonymous visitors.
e621's blacklist entries can be comments (``# ...``), compound expressions
(``young -rating:s``) or wildcards (``*young*``); we extract the positive
plain patterns and match them case-insensitively (negations are ignored, so
matching stays conservative).
The raw list is cached in Redis; ``manage.py refresh_guest_blacklist``
refreshes it from e621 and recomputes the item flags.
"""
import logging
from fnmatch import fnmatch
import requests
from django.conf import settings
from django.core.cache import cache
logger = logging.getLogger(__name__)
CACHE_KEY = "j621.guest_blacklist"
def parse_blacklist_patterns(lines):
"""Extract lowercase tag patterns from e621 blacklist lines."""
patterns = []
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
for token in stripped.split():
if token.startswith("-"):
continue
patterns.append(token.lower())
return patterns
def cached_guest_blacklist():
"""Raw mirrored blacklist lines. Never hits the network."""
stored = cache.get(CACHE_KEY)
if stored is not None:
return list(stored)
return list(settings.GUEST_BLACKLIST_FALLBACK)
def guest_blacklist_patterns():
return parse_blacklist_patterns(cached_guest_blacklist())
def item_hidden_from_guests(tags):
if not tags:
return False
patterns = guest_blacklist_patterns()
if not patterns:
return False
for tag in tags:
candidate = str(tag).strip().lower()
if not candidate:
continue
if any(fnmatch(candidate, pattern) for pattern in patterns):
return True
return False
def fetch_e621_default_blacklist():
"""e621's anonymous default blacklist, or None when unreachable."""
try:
response = requests.get(
f"{settings.E621_BASE_URL}/users/me.json",
headers={"User-Agent": settings.USER_AGENT},
timeout=15,
)
response.raise_for_status()
raw = response.json().get("blacklisted_tags", "")
return [line.strip() for line in raw.split("\n") if line.strip()]
except (requests.RequestException, ValueError) as exc:
logger.warning("Could not fetch e621 default blacklist: %s", exc)
return None
def refresh_guest_blacklist():
"""Refresh the mirrored blacklist and recompute guest visibility flags."""
from .models import MediaItem
lines = fetch_e621_default_blacklist()
used_fallback = lines is None
if used_fallback:
lines = list(settings.GUEST_BLACKLIST_FALLBACK)
cache.set(CACHE_KEY, lines, settings.GUEST_BLACKLIST_TTL)
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
if item.hidden_from_guests != hidden:
item.hidden_from_guests = hidden
updates.append(item)
if updates:
MediaItem.objects.bulk_update(updates, ["hidden_from_guests"])
return {
"blacklist": lines,
"patterns": patterns,
"updated": len(updates),
"fallback": used_fallback,
}
@@ -0,0 +1,20 @@
from django.core.management.base import BaseCommand
from apps.library.guest_filter import refresh_guest_blacklist
class Command(BaseCommand):
help = (
"Mirror e621's anonymous default blacklist and recompute which "
"library items are hidden from guests."
)
def handle(self, *args, **options):
result = refresh_guest_blacklist()
source = "fallback" if result["fallback"] else "e621"
self.stdout.write(
self.style.SUCCESS(
f"Guest blacklist from {source}: {len(result['blacklist'])} tag(s); "
f"{result['updated']} item(s) updated."
)
)
@@ -2,11 +2,14 @@ import os
from pathlib import Path from pathlib import Path
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from apps.library.models import MediaLocation from apps.library.models import MediaLocation
from apps.library.services import ALLOWED_EXTENSIONS, index_file from apps.library.services import ALLOWED_EXTENSIONS, index_file
User = get_user_model()
class Command(BaseCommand): class Command(BaseCommand):
help = "Scan the watched folder and index media files into the library." help = "Scan the watched folder and index media files into the library."
@@ -17,6 +20,10 @@ class Command(BaseCommand):
action="store_true", action="store_true",
help="Remove locations whose files no longer exist on disk.", help="Remove locations whose files no longer exist on disk.",
) )
parser.add_argument(
"--user",
help="Username that owns newly indexed files (default: first superuser).",
)
def handle(self, *args, **options): def handle(self, *args, **options):
folder = Path(settings.WATCHED_FOLDER) folder = Path(settings.WATCHED_FOLDER)
@@ -26,6 +33,26 @@ class Command(BaseCommand):
) )
return return
owner = None
if options["user"]:
owner = User.objects.filter(username=options["user"]).first()
if owner is None:
self.stderr.write(
self.style.ERROR(f"User not found: {options['user']}")
)
return
else:
owner = User.objects.filter(is_superuser=True).order_by("id").first()
if owner is None:
self.stderr.write(
self.style.WARNING(
"No superuser found; scanned files will have no owner. "
"Use --user <name> to assign one."
)
)
if owner is not None:
self.stdout.write(f"New files will be owned by {owner.username}.")
self.stdout.write(f"Scanning {folder} ...") self.stdout.write(f"Scanning {folder} ...")
seen = set() seen = set()
new_items = 0 new_items = 0
@@ -40,11 +67,14 @@ class Command(BaseCommand):
continue continue
path = Path(root) / name path = Path(root) / name
try: try:
_, created_item, created_location = index_file(path, folder) item, created_item, created_location = index_file(path, folder)
except OSError as exc: except OSError as exc:
skipped += 1 skipped += 1
self.stderr.write(f"Skipped {path}: {exc}") self.stderr.write(f"Skipped {path}: {exc}")
continue continue
if owner is not None and item.uploaded_by_id is None:
item.uploaded_by = owner
item.save(update_fields=["uploaded_by", "updated_at"])
seen.add(str(path)) seen.add(str(path))
new_items += int(created_item) new_items += int(created_item)
if created_location: if created_location:
@@ -0,0 +1,26 @@
# Generated by Django 6.1.1 on 2026-09-17 15:01
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='mediaitem',
name='hidden_from_guests',
field=models.BooleanField(db_index=True, default=False),
),
migrations.AddField(
model_name='mediaitem',
name='uploaded_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploads', to=settings.AUTH_USER_MODEL),
),
]
+17 -1
View File
@@ -1,3 +1,4 @@
from django.conf import settings
from django.db import models from django.db import models
@@ -10,6 +11,15 @@ class MediaItem(models.Model):
rating = models.CharField(max_length=1, blank=True, default="") rating = models.CharField(max_length=1, blank=True, default="")
tags = models.JSONField(default=list, blank=True) tags = models.JSONField(default=list, blank=True)
notes = models.TextField(blank=True, default="") notes = models.TextField(blank=True, default="")
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="uploads",
)
# Guests never see items whose tags hit the mirrored default blacklist.
hidden_from_guests = models.BooleanField(default=False, db_index=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -17,7 +27,13 @@ class MediaItem(models.Model):
ordering = ["-created_at"] ordering = ["-created_at"]
def __str__(self): def __str__(self):
return self.md5 return f"J-{self.pk} ({self.md5})"
def save(self, *args, **kwargs):
from .guest_filter import item_hidden_from_guests
self.hidden_from_guests = item_hidden_from_guests(self.tags)
super().save(*args, **kwargs)
class MediaLocation(models.Model): class MediaLocation(models.Model):
+27
View File
@@ -0,0 +1,27 @@
from rest_framework import permissions
class CanUpload(permissions.BasePermission):
"""Only uploader/staff/admin accounts may add items to the library."""
message = "Your account is not allowed to upload."
def has_permission(self, request, view):
user = request.user
return bool(user and user.is_authenticated and user.can_upload)
class IsUploaderOrStaffOrReadOnly(permissions.BasePermission):
"""Owners and staff can change an item; everyone can read."""
message = "Only the uploader or staff can change this item."
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
user = request.user
if not (user and user.is_authenticated):
return False
if user.is_superuser or user.role == user.ROLE_STAFF:
return True
return obj.uploaded_by_id == user.id
+17
View File
@@ -18,13 +18,18 @@ class MediaItemSerializer(serializers.ModelSerializer):
) )
rating = serializers.ChoiceField(choices=["", "s", "q", "e"], required=False) rating = serializers.ChoiceField(choices=["", "s", "q", "e"], required=False)
locations = MediaLocationSerializer(many=True, read_only=True) locations = MediaLocationSerializer(many=True, read_only=True)
j_id = serializers.SerializerMethodField()
filename = serializers.SerializerMethodField() filename = serializers.SerializerMethodField()
extension = serializers.SerializerMethodField() extension = serializers.SerializerMethodField()
kind = serializers.SerializerMethodField() kind = serializers.SerializerMethodField()
uploaded_by = serializers.SerializerMethodField()
uploaded_by_id = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = MediaItem model = MediaItem
fields = [ fields = [
"id",
"j_id",
"md5", "md5",
"size", "size",
"rating", "rating",
@@ -33,21 +38,33 @@ class MediaItemSerializer(serializers.ModelSerializer):
"filename", "filename",
"extension", "extension",
"kind", "kind",
"uploaded_by",
"uploaded_by_id",
"locations", "locations",
"created_at", "created_at",
"updated_at", "updated_at",
] ]
read_only_fields = [ read_only_fields = [
"id",
"j_id",
"md5", "md5",
"size", "size",
"filename", "filename",
"extension", "extension",
"kind", "kind",
"uploaded_by",
"uploaded_by_id",
"locations", "locations",
"created_at", "created_at",
"updated_at", "updated_at",
] ]
def get_j_id(self, obj):
return f"J-{obj.id}"
def get_uploaded_by(self, obj):
return obj.uploaded_by.username if obj.uploaded_by else None
def _first_location(self, obj): def _first_location(self, obj):
locations = list(obj.locations.all()) locations = list(obj.locations.all())
return locations[0] if locations else None return locations[0] if locations else None
+13
View File
@@ -160,6 +160,19 @@ def serve_file(request, path, download=False):
return response return response
def download_file(url, destination):
"""Stream a remote file into destination (used by Download to Library)."""
import requests
headers = {"User-Agent": settings.USER_AGENT}
with requests.get(url, headers=headers, stream=True, timeout=120) as response:
response.raise_for_status()
with open(destination, "wb") as handle:
for chunk in response.iter_content(chunk_size=CHUNK_SIZE):
if chunk:
handle.write(chunk)
def generate_video_thumbnail(md5, path): def generate_video_thumbnail(md5, path):
"""Extract a JPEG thumbnail from a video, cached under MEDIA_ROOT/thumbs.""" """Extract a JPEG thumbnail from a video, cached under MEDIA_ROOT/thumbs."""
if not shutil.which("ffmpeg"): if not shutil.which("ffmpeg"):
+6 -1
View File
@@ -1,7 +1,7 @@
from django.urls import include, path from django.urls import include, path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .views import MediaItemViewSet, UploadView from .views import DownloadToLibraryView, MediaItemViewSet, UploadView
router = DefaultRouter() router = DefaultRouter()
router.register("files", MediaItemViewSet, basename="file") router.register("files", MediaItemViewSet, basename="file")
@@ -9,4 +9,9 @@ router.register("files", MediaItemViewSet, basename="file")
urlpatterns = [ urlpatterns = [
path("", include(router.urls)), path("", include(router.urls)),
path("uploads/", UploadView.as_view(), name="upload"), path("uploads/", UploadView.as_view(), name="upload"),
path(
"online/downloads/",
DownloadToLibraryView.as_view(),
name="download_to_library",
),
] ]
+83 -10
View File
@@ -1,19 +1,25 @@
import re
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse
from django.conf import settings from django.conf import settings
from django.db.models import Min from django.db.models import Min
from django.http import Http404
from django.shortcuts import get_object_or_404
from rest_framework import mixins, status, viewsets from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from . import services from . import services
from .models import MediaItem from .models import MediaItem
from .permissions import CanUpload, IsUploaderOrStaffOrReadOnly
from .serializers import MediaItemSerializer from .serializers import MediaItemSerializer
LIST_ORDERINGS = {"name", "-name", "size", "-size", "created_at", "-created_at"} LIST_ORDERINGS = {"name", "-name", "size", "-size", "created_at", "-created_at"}
MD5_RE = re.compile(r"[0-9a-fA-F]{32}")
class MediaItemViewSet( class MediaItemViewSet(
@@ -23,13 +29,15 @@ class MediaItemViewSet(
viewsets.GenericViewSet, viewsets.GenericViewSet,
): ):
serializer_class = MediaItemSerializer serializer_class = MediaItemSerializer
lookup_field = "md5" permission_classes = [IsAuthenticatedOrReadOnly, IsUploaderOrStaffOrReadOnly]
http_method_names = ["get", "post", "patch", "head", "options"] http_method_names = ["get", "post", "patch", "head", "options"]
def get_queryset(self): def get_queryset(self):
queryset = MediaItem.objects.prefetch_related("locations").annotate( queryset = MediaItem.objects.prefetch_related("locations").annotate(
name=Min("locations__rel_path") name=Min("locations__rel_path")
) )
if not self.request.user.is_authenticated:
queryset = queryset.filter(hidden_from_guests=False)
search = self.request.query_params.get("search", "").strip() search = self.request.query_params.get("search", "").strip()
if search: if search:
queryset = queryset.filter(locations__rel_path__icontains=search) queryset = queryset.filter(locations__rel_path__icontains=search)
@@ -46,8 +54,22 @@ class MediaItemViewSet(
) )
return queryset.distinct() return queryset.distinct()
def get_object(self):
"""Resolve J-<id>, a bare numeric id, or an MD5 fingerprint."""
value = str(self.kwargs.get("pk", "")).strip()
queryset = self.get_queryset()
if MD5_RE.fullmatch(value):
obj = get_object_or_404(queryset, md5=value.lower())
else:
numeric = value[2:] if value.upper().startswith("J-") else value
if not numeric.isdigit():
raise Http404
obj = get_object_or_404(queryset, pk=int(numeric))
self.check_object_permissions(self.request, obj)
return obj
@action(detail=True, methods=["get"]) @action(detail=True, methods=["get"])
def raw(self, request, md5=None): def raw(self, request, pk=None):
item = self.get_object() item = self.get_object()
location = item.locations.first() location = item.locations.first()
if location is None: if location is None:
@@ -60,7 +82,7 @@ class MediaItemViewSet(
) )
@action(detail=True, methods=["get"]) @action(detail=True, methods=["get"])
def thumbnail(self, request, md5=None): def thumbnail(self, request, pk=None):
item = self.get_object() item = self.get_object()
location = item.locations.first() location = item.locations.first()
if location is None: if location is None:
@@ -81,7 +103,7 @@ class MediaItemViewSet(
@action(detail=False, methods=["post"], permission_classes=[AllowAny]) @action(detail=False, methods=["post"], permission_classes=[AllowAny])
def lookup(self, request): def lookup(self, request):
"""Return which of the given MD5s already exist in the library.""" """Report which of the given MD5s exist in the library."""
md5s = request.data.get("md5s") md5s = request.data.get("md5s")
if not isinstance(md5s, list): if not isinstance(md5s, list):
return Response( return Response(
@@ -89,11 +111,19 @@ class MediaItemViewSet(
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
wanted = {str(value).strip().lower()[:32] for value in md5s if value} wanted = {str(value).strip().lower()[:32] for value in md5s if value}
found = MediaItem.objects.filter(md5__in=wanted).values_list("md5", flat=True) queryset = MediaItem.objects.filter(md5__in=wanted)
return Response({"found": sorted(found)}) if not request.user.is_authenticated:
queryset = queryset.filter(hidden_from_guests=False)
rows = list(queryset.values_list("md5", "id"))
return Response(
{
"found": sorted(md5 for md5, _ in rows),
"j_ids": {md5: f"J-{item_id}" for md5, item_id in rows},
}
)
@action(detail=True, methods=["get"]) @action(detail=True, methods=["get"])
def neighbors(self, request, md5=None): def neighbors(self, request, pk=None):
"""Previous/next items in the ordered list, for keyboard navigation.""" """Previous/next items in the ordered list, for keyboard navigation."""
item = self.get_object() item = self.get_object()
ordering = request.query_params.get("ordering", "-created_at").strip() ordering = request.query_params.get("ordering", "-created_at").strip()
@@ -120,6 +150,7 @@ class MediaItemViewSet(
def brief(obj): def brief(obj):
location = obj.locations.first() location = obj.locations.first()
return { return {
"j_id": f"J-{obj.id}",
"md5": obj.md5, "md5": obj.md5,
"filename": Path(location.rel_path).name if location else obj.md5, "filename": Path(location.rel_path).name if location else obj.md5,
} }
@@ -135,7 +166,7 @@ class MediaItemViewSet(
class UploadView(APIView): class UploadView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [CanUpload]
parser_classes = [MultiPartParser, FormParser] parser_classes = [MultiPartParser, FormParser]
def post(self, request): def post(self, request):
@@ -150,7 +181,7 @@ class UploadView(APIView):
for chunk in upload.chunks(): for chunk in upload.chunks():
output.write(chunk) output.write(chunk)
try: try:
item, _, _ = services.index_file(destination, folder) item, created_item, _ = services.index_file(destination, folder)
except Exception as exc: # noqa: BLE001 - report indexing failures except Exception as exc: # noqa: BLE001 - report indexing failures
destination.unlink(missing_ok=True) destination.unlink(missing_ok=True)
return Response( return Response(
@@ -159,6 +190,9 @@ class UploadView(APIView):
) )
update_fields = [] update_fields = []
if created_item and item.uploaded_by_id is None:
item.uploaded_by = request.user
update_fields.append("uploaded_by")
rating = request.data.get("rating") or "" rating = request.data.get("rating") or ""
if rating in {"s", "q", "e"} and item.rating != rating: if rating in {"s", "q", "e"} and item.rating != rating:
item.rating = rating item.rating = rating
@@ -178,3 +212,42 @@ class UploadView(APIView):
MediaItemSerializer(item, context={"request": request}).data, MediaItemSerializer(item, context={"request": request}).data,
status=status.HTTP_201_CREATED, status=status.HTTP_201_CREATED,
) )
class DownloadToLibraryView(APIView):
"""Fetch an e621 file into the watched folder and index it."""
permission_classes = [CanUpload]
def post(self, request):
url = str(request.data.get("url") or "").strip()
post_id = request.data.get("post_id")
filename = str(request.data.get("filename") or "").strip()
if not url.startswith(("http://", "https://")):
return Response(
{"detail": "A valid file URL is required."},
status=status.HTTP_400_BAD_REQUEST,
)
folder = Path(settings.WATCHED_FOLDER)
name = (
filename
or Path(urlparse(url).path).name
or f"post-{post_id or 'download'}"
)
destination = services.unique_destination(folder, name)
try:
services.download_file(url, destination)
item, _, _ = services.index_file(destination, folder)
except Exception as exc: # noqa: BLE001 - report download failures
destination.unlink(missing_ok=True)
return Response(
{"detail": f"Download failed: {exc}"},
status=status.HTTP_400_BAD_REQUEST,
)
if item.uploaded_by_id is None:
item.uploaded_by = request.user
item.save(update_fields=["uploaded_by", "updated_at"])
return Response(
MediaItemSerializer(item, context={"request": request}).data,
status=status.HTTP_201_CREATED,
)
+13
View File
@@ -0,0 +1,13 @@
"""Django project package.
Use PyMySQL as the MySQL/MariaDB driver. Django checks the driver library
version, so report a compatible one before installing the shim (same trick
the original J621 used).
"""
import pymysql
pymysql.version_info = (2, 2, 1, "final", 0)
pymysql.__version__ = "2.2.1"
pymysql.install_as_MySQLdb()
+34 -3
View File
@@ -85,12 +85,17 @@ TEMPLATES = [
WSGI_APPLICATION = "config.wsgi.application" WSGI_APPLICATION = "config.wsgi.application"
# Database # Database (MariaDB, run via docker compose at the repo root)
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.sqlite3", "ENGINE": "django.db.backends.mysql",
"NAME": BASE_DIR / "db.sqlite3", "NAME": os.getenv("DB_NAME", "j621"),
"USER": os.getenv("DB_USER", "j621"),
"PASSWORD": os.getenv("DB_PASSWORD", "j621"),
"HOST": os.getenv("DB_HOST", "127.0.0.1"),
"PORT": os.getenv("DB_PORT", "3307"),
"OPTIONS": {"charset": "utf8mb4"},
} }
} }
@@ -135,6 +140,32 @@ if not WATCHED_FOLDER.is_absolute():
WATCHED_FOLDER = BASE_DIR / WATCHED_FOLDER WATCHED_FOLDER = BASE_DIR / WATCHED_FOLDER
WATCHED_FOLDER = str(WATCHED_FOLDER) WATCHED_FOLDER = str(WATCHED_FOLDER)
# e621 integration
E621_BASE_URL = os.getenv("E621_BASE_URL", "https://e621.net").rstrip("/")
USER_AGENT = os.getenv("USER_AGENT", "J621/0.1 (by J621 on e621)")
# Guest visibility: e621's anonymous default blacklist is mirrored into the
# cache by `manage.py refresh_guest_blacklist`. This fallback is used until
# that command runs or when e621 is unreachable.
GUEST_BLACKLIST_FALLBACK = [
tag.strip()
for tag in os.getenv(
"GUEST_BLACKLIST_FALLBACK", "young,cub,shota,loli,child,underage"
).split(",")
if tag.strip()
]
GUEST_BLACKLIST_TTL = int(os.getenv("GUEST_BLACKLIST_TTL", "3600"))
# Redis cache (run via docker compose at the repo root), shared by web
# workers and management commands (e.g. the mirrored guest blacklist).
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": os.getenv("REDIS_URL", "redis://127.0.0.1:6380/1"),
}
}
# Django REST Framework # Django REST Framework
REST_FRAMEWORK = { REST_FRAMEWORK = {
+3
View File
@@ -3,3 +3,6 @@ djangorestframework>=3.17
django-filter>=25.1 django-filter>=25.1
Pillow>=11.0 Pillow>=11.0
python-dotenv>=1.0 python-dotenv>=1.0
requests>=2.32
PyMySQL>=1.1
redis>=5.0
+42
View File
@@ -0,0 +1,42 @@
name: j621
services:
mariadb:
image: mariadb:11.4
container_name: j621-mariadb
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-j621root}
MARIADB_DATABASE: ${DB_NAME:-j621}
MARIADB_USER: ${DB_USER:-j621}
MARIADB_PASSWORD: ${DB_PASSWORD:-j621}
ports:
# Host port 3307 is free; 3305/3406 belong to other local projects.
- "${DB_PORT:-3307}:3306"
volumes:
- mariadb-data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 20
redis:
image: redis:7-alpine
container_name: j621-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
ports:
# Host port 6379 is taken by another local project's Redis.
- "${REDIS_PORT:-6380}:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 20
volumes:
mariadb-data:
redis-data:
+17 -5
View File
@@ -1,14 +1,20 @@
import { useEffect, type ReactNode } from "react"; import { useEffect, type ReactNode } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router-dom"; import {
Navigate,
Route,
Routes,
useLocation,
useParams,
} from "react-router-dom";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { FullPageSpinner } from "@/components/ui"; import { FullPageSpinner } from "@/components/ui";
import AccountPage from "@/features/account/AccountPage"; import AccountPage from "@/features/account/AccountPage";
import LoginPage from "@/features/auth/LoginPage"; import LoginPage from "@/features/auth/LoginPage";
import RegisterPage from "@/features/auth/RegisterPage"; import RegisterPage from "@/features/auth/RegisterPage";
import FileDetailPage from "@/features/library/FileDetailPage"; import DetailPage from "@/features/detail/DetailPage";
import LibraryPage from "@/features/library/LibraryPage"; import LibraryPage from "@/features/library/LibraryPage";
import OnlineDetailPage from "@/features/online/OnlineDetailPage"; import Md5Redirect from "@/features/library/Md5Redirect";
import OnlinePage from "@/features/online/OnlinePage"; import OnlinePage from "@/features/online/OnlinePage";
import UploadPage from "@/features/upload/UploadPage"; import UploadPage from "@/features/upload/UploadPage";
import { useAuth } from "@/store/auth"; import { useAuth } from "@/store/auth";
@@ -22,6 +28,11 @@ function RequireAuth({ children }: { children: ReactNode }) {
return <>{children}</>; return <>{children}</>;
} }
function PostRedirect() {
const { postId = "" } = useParams();
return <Navigate to={`/detail/${postId}`} replace />;
}
export default function App() { export default function App() {
const ready = useAuth((state) => state.ready); const ready = useAuth((state) => state.ready);
const loadUser = useAuth((state) => state.loadUser); const loadUser = useAuth((state) => state.loadUser);
@@ -38,9 +49,10 @@ export default function App() {
<Routes> <Routes>
<Route element={<AppShell />}> <Route element={<AppShell />}>
<Route path="/" element={<LibraryPage />} /> <Route path="/" element={<LibraryPage />} />
<Route path="/view/:md5" element={<FileDetailPage />} /> <Route path="/detail/:itemId" element={<DetailPage />} />
<Route path="/view/:md5" element={<Md5Redirect />} />
<Route path="/online" element={<OnlinePage />} /> <Route path="/online" element={<OnlinePage />} />
<Route path="/online/view/:id" element={<OnlineDetailPage />} /> <Route path="/online/view/:postId" element={<PostRedirect />} />
<Route <Route
path="/upload" path="/upload"
element={ element={
+6 -6
View File
@@ -13,12 +13,6 @@ import { useAuth } from "@/store/auth";
import { useE621 } from "@/store/e621"; import { useE621 } from "@/store/e621";
import { useUi } from "@/store/ui"; import { useUi } from "@/store/ui";
const navItems = [
{ to: "/", label: "Library" },
{ to: "/online", label: "Online" },
{ to: "/upload", label: "Upload" },
];
export function AppShell() { export function AppShell() {
const user = useAuth((state) => state.user); const user = useAuth((state) => state.user);
const logout = useAuth((state) => state.logout); const logout = useAuth((state) => state.logout);
@@ -63,6 +57,12 @@ export function AppShell() {
const showSidebarToggle = const showSidebarToggle =
location.pathname === "/" || location.pathname === "/online"; location.pathname === "/" || location.pathname === "/online";
const navItems = [
{ to: "/", label: "Library" },
{ to: "/online", label: "Online" },
...(user?.can_upload ? [{ to: "/upload", label: "Upload" }] : []),
];
const navigationType = useNavigationType(); const navigationType = useNavigationType();
useEffect(() => { useEffect(() => {
// New navigations start at the top; history back/forward keeps position. // New navigations start at the top; history back/forward keeps position.
@@ -51,12 +51,16 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
icon: FolderOpen, icon: FolderOpen,
run: () => navigate("/"), run: () => navigate("/"),
}, },
...(user?.can_upload
? [
{ {
id: "upload", id: "upload",
label: "Go to Upload", label: "Go to Upload",
icon: Upload, icon: Upload,
run: () => navigate("/upload"), run: () => navigate("/upload"),
}, },
]
: []),
{ {
id: "online", id: "online",
label: "Go to Online browser", label: "Go to Online browser",
+8 -4
View File
@@ -19,12 +19,12 @@ const ratingLabels: Record<string, string> = {
export function MediaCard({ item }: { item: MediaItem }) { export function MediaCard({ item }: { item: MediaItem }) {
const preview = const preview =
item.kind === "video" item.kind === "video"
? `/api/files/${item.md5}/thumbnail/` ? `/api/files/${item.j_id}/thumbnail/`
: `/api/files/${item.md5}/raw/`; : `/api/files/${item.j_id}/raw/`;
return ( return (
<Link <Link
to={`/view/${item.md5}`} to={`/detail/${item.j_id}`}
className="group overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base transition hover:border-ctp-surface2" className="group overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base transition hover:border-ctp-surface2"
> >
<div className="relative aspect-square overflow-hidden bg-ctp-mantle"> <div className="relative aspect-square overflow-hidden bg-ctp-mantle">
@@ -54,7 +54,11 @@ export function MediaCard({ item }: { item: MediaItem }) {
</div> </div>
</div> </div>
<div className="px-2.5 py-2"> <div className="px-2.5 py-2">
<p className="truncate font-mono text-xs text-ctp-subtext1" title={item.filename}> <p className="font-mono text-[10px] text-ctp-overlay0">{item.j_id}</p>
<p
className="truncate font-mono text-xs text-ctp-subtext1"
title={item.filename}
>
{item.filename} {item.filename}
</p> </p>
</div> </div>
+3
View File
@@ -72,6 +72,9 @@ export function Field({
export const inputClass = export const inputClass =
"w-full rounded-md border border-ctp-surface0 bg-ctp-mantle px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay0 focus:border-ctp-mauve focus:outline-none"; "w-full rounded-md border border-ctp-surface0 bg-ctp-mantle px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay0 focus:border-ctp-mauve focus:outline-none";
export const linkButtonClass =
"inline-flex items-center justify-center gap-2 rounded-md border border-ctp-surface1 bg-ctp-surface0 px-3 py-1.5 text-sm font-medium text-ctp-text transition hover:bg-ctp-surface1";
export function EmptyState({ export function EmptyState({
title, title,
description, description,
@@ -230,8 +230,11 @@ export default function AccountPage() {
<header> <header>
<h1 className="text-lg font-semibold">Account</h1> <h1 className="text-lg font-semibold">Account</h1>
{user ? ( {user ? (
<p className="mt-1 text-sm text-ctp-overlay0"> <p className="mt-1 flex items-center gap-2 text-sm text-ctp-overlay0">
Signed in as {user.username}. <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>
</p> </p>
) : null} ) : null}
</header> </header>
@@ -0,0 +1,32 @@
import { useParams } from "react-router-dom";
import { EmptyState } from "@/components/ui";
import { LibraryDetail } from "@/features/library/LibraryDetail";
import { OnlineDetail } from "@/features/online/OnlineDetail";
const J_ID_RE = /^J-\d+$/i;
export default function DetailPage() {
const { itemId = "" } = useParams();
if (J_ID_RE.test(itemId)) {
const normalized = `J-${itemId.slice(2)}`;
return <LibraryDetail jId={normalized} />;
}
if (/^\d+$/.test(itemId)) {
return <OnlineDetail postId={Number(itemId)} />;
}
return (
<EmptyState
title="Unknown item"
description={
<span className="font-mono text-xs">
Use /detail/J-&lt;id&gt; for library items or /detail/&lt;id&gt; for
e621 posts.
</span>
}
/>
);
}
@@ -0,0 +1,29 @@
import type { ReactNode } from "react";
export function DetailShell({
breadcrumb,
actions,
media,
aside,
footer,
}: {
breadcrumb: ReactNode;
actions?: ReactNode;
media: ReactNode;
aside: ReactNode;
footer?: ReactNode;
}) {
return (
<div className="flex flex-col gap-4">
{breadcrumb}
<div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
<aside className="order-2 flex flex-col gap-4 lg:order-1">{aside}</aside>
<div className="order-1 flex min-w-0 flex-col gap-4 lg:order-2">
{actions}
{media}
{footer}
</div>
</div>
</div>
);
}
@@ -1,15 +1,17 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Download } from "lucide-react"; import { ArrowLeft, Download } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui"; import { Button, EmptyState, Spinner, inputClass, linkButtonClass } from "@/components/ui";
import { DetailShell } from "@/features/detail/DetailShell";
import { api, errorMessage } from "@/lib/api"; import { api, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { isTypingTarget } from "@/lib/dom"; import { isTypingTarget } from "@/lib/dom";
import { formatBytes, formatDate } from "@/lib/format"; import { formatBytes, formatDate } from "@/lib/format";
import { useUi } from "@/store/ui";
import type { FileNeighbors, MediaItem, Rating } from "@/lib/types"; import type { FileNeighbors, MediaItem, Rating } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { useUi } from "@/store/ui";
const ratingChoices: { value: Rating; label: string; active: string }[] = [ const ratingChoices: { value: Rating; label: string; active: string }[] = [
{ {
@@ -34,7 +36,13 @@ const ratingChoices: { value: Rating; label: string; active: string }[] = [
}, },
]; ];
function CustomDataCard({ item }: { item: MediaItem }) { const ratingLabel: Record<string, string> = {
s: "Safe",
q: "Questionable",
e: "Explicit",
};
function CustomDataCard({ item, canEdit }: { item: MediaItem; canEdit: boolean }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [rating, setRating] = useState<Rating>(item.rating); const [rating, setRating] = useState<Rating>(item.rating);
const [tags, setTags] = useState(item.tags.join(", ")); const [tags, setTags] = useState(item.tags.join(", "));
@@ -42,16 +50,49 @@ function CustomDataCard({ item }: { item: MediaItem }) {
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (payload: { rating: Rating; tags: string[]; notes: string }) => mutationFn: (payload: { rating: Rating; tags: string[]; notes: string }) =>
api<MediaItem>(`/api/files/${item.md5}/`, { api<MediaItem>(`/api/files/${item.j_id}/`, {
method: "PATCH", method: "PATCH",
json: payload, json: payload,
}), }),
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["file", item.md5] }); void queryClient.invalidateQueries({ queryKey: ["file", item.j_id] });
void queryClient.invalidateQueries({ queryKey: ["files"] }); void queryClient.invalidateQueries({ queryKey: ["files"] });
}, },
}); });
if (!canEdit) {
return (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<h2 className="text-sm font-semibold text-ctp-subtext1">Custom data</h2>
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
<dt className="text-ctp-overlay0">Rating</dt>
<dd className="text-ctp-subtext0">
{item.rating ? ratingLabel[item.rating] : "None"}
</dd>
<dt className="text-ctp-overlay0">Tags</dt>
<dd className="flex flex-wrap gap-1">
{item.tags.length > 0 ? (
item.tags.map((tag) => (
<span
key={tag}
className="rounded border border-ctp-surface1 px-1.5 py-0.5 font-mono text-[11px] text-ctp-subtext0"
>
{tag}
</span>
))
) : (
<span className="text-ctp-subtext0">—</span>
)}
</dd>
<dt className="text-ctp-overlay0">Notes</dt>
<dd className="whitespace-pre-line text-ctp-subtext0">
{item.notes || "—"}
</dd>
</dl>
</section>
);
}
function save() { function save() {
mutation.mutate({ mutation.mutate({
rating, rating,
@@ -114,25 +155,23 @@ function CustomDataCard({ item }: { item: MediaItem }) {
); );
} }
export default function FileDetailPage() { export function LibraryDetail({ jId }: { jId: string }) {
const { md5 = "" } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const ordering = useUi((state) => state.ordering); const ordering = useUi((state) => state.ordering);
const rawUrl = `/api/files/${md5}/raw/`; const user = useAuth((state) => state.user);
const rawUrl = `/api/files/${jId}/raw/`;
const query = useQuery({ const query = useQuery({
queryKey: ["file", md5], queryKey: ["file", jId],
queryFn: () => api<MediaItem>(`/api/files/${md5}/`), queryFn: () => api<MediaItem>(`/api/files/${jId}/`),
enabled: md5.length > 0,
}); });
const neighborsQuery = useQuery({ const neighborsQuery = useQuery({
queryKey: ["neighbors", md5, ordering], queryKey: ["neighbors", jId, ordering],
queryFn: () => queryFn: () =>
api<FileNeighbors>( api<FileNeighbors>(
`/api/files/${md5}/neighbors/?ordering=${encodeURIComponent(ordering)}`, `/api/files/${jId}/neighbors/?ordering=${encodeURIComponent(ordering)}`,
), ),
enabled: md5.length > 0,
}); });
useEffect(() => { useEffect(() => {
@@ -146,9 +185,9 @@ export default function FileDetailPage() {
} }
const neighbors = neighborsQuery.data; const neighbors = neighborsQuery.data;
if (event.key === "[" && neighbors?.previous) { if (event.key === "[" && neighbors?.previous) {
navigate(`/view/${neighbors.previous.md5}`); navigate(`/detail/${neighbors.previous.j_id}`);
} else if (event.key === "]" && neighbors?.next) { } else if (event.key === "]" && neighbors?.next) {
navigate(`/view/${neighbors.next.md5}`); navigate(`/detail/${neighbors.next.j_id}`);
} }
} }
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
@@ -177,9 +216,16 @@ export default function FileDetailPage() {
} }
const item = query.data; const item = query.data;
const canEdit = Boolean(
user &&
(user.id === item.uploaded_by_id ||
user.is_staff ||
user.role === "staff"),
);
return ( return (
<div className="flex flex-col gap-4"> <DetailShell
breadcrumb={
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<Link <Link
to="/" to="/"
@@ -188,16 +234,21 @@ export default function FileDetailPage() {
<ArrowLeft className="h-4 w-4" /> Library <ArrowLeft className="h-4 w-4" /> Library
</Link> </Link>
<span className="text-ctp-surface1">/</span> <span className="text-ctp-surface1">/</span>
<span className="truncate font-mono text-xs text-ctp-overlay0"> <span className="font-mono text-xs text-ctp-overlay0">{item.j_id}</span>
{item.filename}
</span>
</div> </div>
}
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]"> actions={
<div className="flex flex-wrap items-center gap-2">
<a href={`${rawUrl}?download=1`} className={linkButtonClass}>
<Download className="h-4 w-4" /> Download
</a>
</div>
}
media={
<div className="flex min-h-[320px] items-center justify-center overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-mantle"> <div className="flex min-h-[320px] items-center justify-center overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-mantle">
{item.kind === "video" ? ( {item.kind === "video" ? (
<video <video
key={item.md5} key={item.j_id}
src={rawUrl} src={rawUrl}
controls controls
autoPlay autoPlay
@@ -207,15 +258,16 @@ export default function FileDetailPage() {
/> />
) : ( ) : (
<img <img
key={item.md5} key={item.j_id}
src={rawUrl} src={rawUrl}
alt={item.filename} alt={item.filename}
className="max-h-[75vh] w-full object-contain" className="max-h-[75vh] w-full object-contain"
/> />
)} )}
</div> </div>
}
<div className="flex flex-col gap-4"> aside={
<>
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4"> <section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<h1 <h1
className="truncate font-mono text-sm text-ctp-subtext1" className="truncate font-mono text-sm text-ctp-subtext1"
@@ -224,6 +276,8 @@ export default function FileDetailPage() {
{item.filename} {item.filename}
</h1> </h1>
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs"> <dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
<dt className="text-ctp-overlay0">ID</dt>
<dd className="font-mono text-ctp-subtext0">{item.j_id}</dd>
<dt className="text-ctp-overlay0">MD5</dt> <dt className="text-ctp-overlay0">MD5</dt>
<dd className="truncate font-mono text-ctp-subtext0" title={item.md5}> <dd className="truncate font-mono text-ctp-subtext0" title={item.md5}>
{item.md5} {item.md5}
@@ -238,20 +292,14 @@ export default function FileDetailPage() {
</dd> </dd>
<dt className="text-ctp-overlay0">Added</dt> <dt className="text-ctp-overlay0">Added</dt>
<dd className="text-ctp-subtext0">{formatDate(item.created_at)}</dd> <dd className="text-ctp-subtext0">{formatDate(item.created_at)}</dd>
<dt className="text-ctp-overlay0">Uploaded by</dt>
<dd className="text-ctp-subtext0">{item.uploaded_by ?? "—"}</dd>
</dl> </dl>
<a
href={`${rawUrl}?download=1`}
className="mt-4 inline-flex items-center justify-center gap-2 rounded-md border border-ctp-surface1 bg-ctp-surface0 px-3 py-1.5 text-sm font-medium text-ctp-text transition hover:bg-ctp-surface1"
>
<Download className="h-4 w-4" /> Download
</a>
<p className="mt-3 font-mono text-[11px] text-ctp-overlay0"> <p className="mt-3 font-mono text-[11px] text-ctp-overlay0">
[ ] navigate · D download [ ] navigate · D download
</p> </p>
</section> </section>
<CustomDataCard key={item.updated_at} item={item} />
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4"> <section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<h2 className="text-sm font-semibold text-ctp-subtext1"> <h2 className="text-sm font-semibold text-ctp-subtext1">
Locations ({item.locations.length}) Locations ({item.locations.length})
@@ -268,8 +316,14 @@ export default function FileDetailPage() {
))} ))}
</ul> </ul>
</section> </section>
</div>
</div> <CustomDataCard
</div> key={`${item.j_id}-${item.updated_at}`}
item={item}
canEdit={canEdit}
/>
</>
}
/>
); );
} }
@@ -8,6 +8,7 @@ import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { isTypingTarget } from "@/lib/dom"; import { isTypingTarget } from "@/lib/dom";
import { useAuth } from "@/store/auth";
import { useUi } from "@/store/ui"; import { useUi } from "@/store/ui";
import type { MediaItem, Paginated } from "@/lib/types"; import type { MediaItem, Paginated } from "@/lib/types";
@@ -51,6 +52,7 @@ export default function LibraryPage() {
const setPerPage = useUi((state) => state.setPerPage); const setPerPage = useUi((state) => state.setPerPage);
const ordering = useUi((state) => state.ordering); const ordering = useUi((state) => state.ordering);
const setOrdering = useUi((state) => state.setOrdering); const setOrdering = useUi((state) => state.setOrdering);
const user = useAuth((state) => state.user);
useEffect(() => { useEffect(() => {
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
@@ -214,6 +216,12 @@ export default function LibraryPage() {
) : null} ) : null}
</div> </div>
{!user ? (
<p className="mb-4 rounded-md border border-ctp-surface1 bg-ctp-base px-3 py-2 text-xs text-ctp-overlay0">
Browsing as a guest — some items may be hidden.
</p>
) : null}
{query.isPending ? ( {query.isPending ? (
<div className="flex justify-center py-24"> <div className="flex justify-center py-24">
<Spinner className="h-6 w-6" /> <Spinner className="h-6 w-6" />
@@ -0,0 +1,39 @@
import { useQuery } from "@tanstack/react-query";
import { Navigate, useParams } from "react-router-dom";
import { EmptyState, Spinner } from "@/components/ui";
import { api } from "@/lib/api";
import type { MediaItem } from "@/lib/types";
const MD5_RE = /^[0-9a-fA-F]{32}$/;
export default function Md5Redirect() {
const { md5 = "" } = useParams();
const valid = MD5_RE.test(md5);
const query = useQuery({
queryKey: ["resolve-md5", md5.toLowerCase()],
queryFn: () => api<MediaItem>(`/api/files/${md5.toLowerCase()}/`),
enabled: valid,
retry: 0,
});
if (valid && query.isPending) {
return (
<div className="flex justify-center py-24">
<Spinner className="h-6 w-6" />
</div>
);
}
if (query.isError || !query.data) {
return (
<EmptyState
title="File not found"
description="This link may be outdated, or the file is not available."
/>
);
}
return <Navigate to={`/detail/${query.data.j_id}`} replace />;
}
@@ -8,10 +8,11 @@ import {
HeartOff, HeartOff,
} from "lucide-react"; } from "lucide-react";
import { useMemo } from "react"; import { useMemo } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { Button, EmptyState, Spinner } from "@/components/ui"; import { Button, EmptyState, Spinner, linkButtonClass } from "@/components/ui";
import { TagChip } from "@/components/TagChip"; import { TagChip } from "@/components/TagChip";
import { DetailShell } from "@/features/detail/DetailShell";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { renderDText } from "@/lib/dtext"; import { renderDText } from "@/lib/dtext";
@@ -26,6 +27,8 @@ import {
TAG_CATEGORY_ORDER, TAG_CATEGORY_ORDER,
} from "@/lib/e621"; } from "@/lib/e621";
import { formatBytes, formatDate } from "@/lib/format"; import { formatBytes, formatDate } from "@/lib/format";
import type { MediaItem } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { useE621 } from "@/store/e621"; import { useE621 } from "@/store/e621";
const ratingText: Record<string, string> = { const ratingText: Record<string, string> = {
@@ -40,17 +43,18 @@ const relatedBorder: Record<string, string> = {
e: "border-ctp-red/25 hover:border-ctp-red/60", e: "border-ctp-red/25 hover:border-ctp-red/60",
}; };
const linkButtonClass = interface LibraryLookup {
"inline-flex items-center justify-center gap-2 rounded-md border border-ctp-surface1 bg-ctp-surface0 px-3 py-1.5 text-sm font-medium text-ctp-text transition hover:bg-ctp-surface1"; found: string[];
j_ids: Record<string, string>;
}
export default function OnlineDetailPage() { export function OnlineDetail({ postId }: { postId: number }) {
const { id = "" } = useParams();
const postId = Number(id);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const from = (location.state as { from?: string } | null)?.from ?? "/online"; const from = (location.state as { from?: string } | null)?.from ?? "/online";
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const credentials = useE621((state) => state.credentials); const credentials = useE621((state) => state.credentials);
const user = useAuth((state) => state.user);
const query = useQuery({ const query = useQuery({
queryKey: ["e621", "post", postId, credentials?.username ?? ""], queryKey: ["e621", "post", postId, credentials?.username ?? ""],
@@ -91,14 +95,16 @@ export default function OnlineDetailPage() {
const lookupQuery = useQuery({ const lookupQuery = useQuery({
queryKey: ["library-lookup", post?.file.md5 ?? ""], queryKey: ["library-lookup", post?.file.md5 ?? ""],
queryFn: () => queryFn: () =>
api<{ found: string[] }>("/api/files/lookup/", { api<LibraryLookup>("/api/files/lookup/", {
method: "POST", method: "POST",
json: { md5s: [post?.file.md5] }, json: { md5s: [post?.file.md5] },
}), }),
enabled: Boolean(post?.file.md5), enabled: Boolean(post?.file.md5),
staleTime: 60_000, staleTime: 60_000,
}); });
const inLibrary = (lookupQuery.data?.found.length ?? 0) > 0; const libraryJId = post
? lookupQuery.data?.j_ids?.[post.file.md5]
: undefined;
const favoriteMutation = useMutation({ const favoriteMutation = useMutation({
mutationFn: (favorite: boolean) => mutationFn: (favorite: boolean) =>
@@ -110,6 +116,23 @@ export default function OnlineDetailPage() {
}, },
}); });
const downloadMutation = useMutation({
mutationFn: (target: NonNullable<typeof post>) =>
api<MediaItem>("/api/online/downloads/", {
method: "POST",
json: {
post_id: target.id,
url: target.file.url,
filename: `${target.file.md5}.${target.file.ext}`,
},
}),
onSuccess: (item) => {
void queryClient.invalidateQueries({ queryKey: ["library-lookup"] });
void queryClient.invalidateQueries({ queryKey: ["files"] });
navigate(`/detail/${item.j_id}`);
},
});
if (query.isPending) { if (query.isPending) {
return ( return (
<div className="flex justify-center py-24"> <div className="flex justify-center py-24">
@@ -138,9 +161,11 @@ export default function OnlineDetailPage() {
const isVideo = post.file.ext === "mp4" || post.file.ext === "webm"; const isVideo = post.file.ext === "mp4" || post.file.ext === "webm";
const imageSrc = const imageSrc =
post.sample.has && post.sample.url ? post.sample.url : post.file.url; post.sample.has && post.sample.url ? post.sample.url : post.file.url;
const canUpload = Boolean(user?.can_upload);
return ( return (
<div className="flex flex-col gap-4"> <DetailShell
breadcrumb={
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<Link <Link
to={from} to={from}
@@ -153,9 +178,102 @@ export default function OnlineDetailPage() {
Post #{post.id} Post #{post.id}
</span> </span>
</div> </div>
}
actions={
<>
<div className="flex flex-wrap items-center gap-2">
<Button
variant={post.is_favorited ? "secondary" : "primary"}
disabled={!credentials?.configured || favoriteMutation.isPending}
onClick={() => favoriteMutation.mutate(!post.is_favorited)}
title={
credentials?.configured
? undefined
: "Add e621 credentials in Account to favorite posts"
}
>
{post.is_favorited ? (
<HeartOff className="h-4 w-4" />
) : (
<Heart className="h-4 w-4" />
)}
{favoriteMutation.isPending
? "Saving…"
: post.is_favorited
? "Unfavorite"
: "Favorite"}
</Button>
<div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]"> {libraryJId ? (
<aside className="order-2 flex flex-col gap-4 lg:order-1"> <Link to={`/detail/${libraryJId}`} className={linkButtonClass}>
<CheckCircle2 className="h-4 w-4" /> In library ({libraryJId})
</Link>
) : (
<Button
variant="secondary"
disabled={
!canUpload || downloadMutation.isPending || !post.file.url
}
onClick={() => downloadMutation.mutate(post)}
title={
canUpload
? undefined
: "Your account is not allowed to add to the library"
}
>
<Download className="h-4 w-4" />
{downloadMutation.isPending
? "Downloading…"
: "Download to Library"}
</Button>
)}
<a
href={`https://e621.net/posts/${post.id}`}
target="_blank"
rel="noreferrer"
className={linkButtonClass}
>
<ExternalLink className="h-4 w-4" /> View on e621
</a>
</div>
{favoriteMutation.isError ? (
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
{e621ErrorMessage(favoriteMutation.error)}
</p>
) : null}
{downloadMutation.isError ? (
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
{e621ErrorMessage(downloadMutation.error)}
</p>
) : null}
</>
}
media={
<div className="flex min-h-[320px] items-center justify-center overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-mantle">
{isVideo ? (
<video
key={post.id}
src={post.file.url ?? undefined}
controls
autoPlay
loop
muted
className="max-h-[75vh] w-full object-contain"
/>
) : (
<img
key={post.id}
src={imageSrc ?? undefined}
alt={`Post #${post.id}`}
className="max-h-[75vh] w-full object-contain"
/>
)}
</div>
}
aside={
<>
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4"> <section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<h2 className="text-sm font-semibold text-ctp-subtext1">Tags</h2> <h2 className="text-sm font-semibold text-ctp-subtext1">Tags</h2>
<div className="mt-3 flex flex-col gap-3"> <div className="mt-3 flex flex-col gap-3">
@@ -226,81 +344,11 @@ export default function OnlineDetailPage() {
</> </>
) : null} ) : null}
</dl> </dl>
{inLibrary ? (
<p className="mt-3 flex items-center gap-1.5 text-xs text-ctp-green">
<CheckCircle2 className="h-3.5 w-3.5" /> In your library
</p>
) : null}
</section> </section>
</aside> </>
<div className="order-1 flex min-w-0 flex-col gap-4 lg:order-2">
<div className="flex flex-wrap items-center gap-2">
<Button
variant={post.is_favorited ? "secondary" : "primary"}
disabled={!credentials?.configured || favoriteMutation.isPending}
onClick={() => favoriteMutation.mutate(!post.is_favorited)}
title={
credentials?.configured
? undefined
: "Add e621 credentials in Account to favorite posts"
} }
> footer={
{post.is_favorited ? ( <>
<HeartOff className="h-4 w-4" />
) : (
<Heart className="h-4 w-4" />
)}
{favoriteMutation.isPending
? "Saving…"
: post.is_favorited
? "Unfavorite"
: "Favorite"}
</Button>
<Button
variant="secondary"
disabled
title="Coming with the Download to Library update"
>
<Download className="h-4 w-4" /> Download to Library
</Button>
<a
href={`https://e621.net/posts/${post.id}`}
target="_blank"
rel="noreferrer"
className={linkButtonClass}
>
<ExternalLink className="h-4 w-4" /> View on e621
</a>
</div>
{favoriteMutation.isError ? (
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
{e621ErrorMessage(favoriteMutation.error)}
</p>
) : null}
<div className="flex min-h-[320px] items-center justify-center overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-mantle">
{isVideo ? (
<video
key={post.id}
src={post.file.url ?? undefined}
controls
autoPlay
loop
muted
className="max-h-[75vh] w-full object-contain"
/>
) : (
<img
key={post.id}
src={imageSrc ?? undefined}
alt={`Post #${post.id}`}
className="max-h-[75vh] w-full object-contain"
/>
)}
</div>
{post.description.trim() ? ( {post.description.trim() ? (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4"> <section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<details> <details>
@@ -368,7 +416,7 @@ export default function OnlineDetailPage() {
{(relatedQuery.data ?? []).map((related) => ( {(relatedQuery.data ?? []).map((related) => (
<Link <Link
key={related.id} key={related.id}
to={`/online/view/${related.id}`} to={`/detail/${related.id}`}
state={{ from }} state={{ from }}
className={cn( className={cn(
"overflow-hidden rounded-md border bg-ctp-mantle transition", "overflow-hidden rounded-md border bg-ctp-mantle transition",
@@ -394,8 +442,8 @@ export default function OnlineDetailPage() {
)} )}
</section> </section>
) : null} ) : null}
</div> </>
</div> }
</div> />
); );
} }
+1 -1
View File
@@ -28,7 +28,7 @@ export function PostCard({
}) { }) {
return ( return (
<Link <Link
to={`/online/view/${post.id}`} to={`/detail/${post.id}`}
state={from ? { from } : undefined} state={from ? { from } : undefined}
className={cn( className={cn(
"group overflow-hidden rounded-lg border bg-ctp-base transition", "group overflow-hidden rounded-lg border bg-ctp-base transition",
+1 -1
View File
@@ -22,7 +22,7 @@ export function PostThumb({ postId }: { postId: number }) {
return ( return (
<span className="my-1 inline-block align-top"> <span className="my-1 inline-block align-top">
<Link <Link
to={`/online/view/${postId}`} to={`/detail/${postId}`}
title={`Post #${postId}`} title={`Post #${postId}`}
className="group inline-block" className="group inline-block"
> >
@@ -6,6 +6,7 @@ import { Button, EmptyState } from "@/components/ui";
import { errorMessage, uploadFile } from "@/lib/api"; import { errorMessage, uploadFile } from "@/lib/api";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { formatBytes } from "@/lib/format"; import { formatBytes } from "@/lib/format";
import { useAuth } from "@/store/auth";
type UploadStatus = "queued" | "uploading" | "done" | "error"; type UploadStatus = "queued" | "uploading" | "done" | "error";
@@ -19,6 +20,7 @@ interface UploadEntry {
export default function UploadPage() { export default function UploadPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const user = useAuth((state) => state.user);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [entries, setEntries] = useState<UploadEntry[]>([]); const [entries, setEntries] = useState<UploadEntry[]>([]);
@@ -81,6 +83,20 @@ export default function UploadPage() {
setEntries((current) => current.filter((entry) => entry.status !== "done")); setEntries((current) => current.filter((entry) => entry.status !== "done"));
} }
if (user && !user.can_upload) {
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
<header>
<h1 className="text-lg font-semibold">Upload</h1>
</header>
<EmptyState
title="Uploads are restricted"
description="Your account doesn't have the uploader role yet. Ask an admin to grant it."
/>
</div>
);
}
return ( return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6"> <div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
<header> <header>
+9
View File
@@ -4,6 +4,10 @@ export interface User {
email: string; email: string;
is_staff: boolean; is_staff: boolean;
date_joined: string; date_joined: string;
role: "user" | "uploader" | "staff";
can_upload: boolean;
e621_username: string;
e621_configured: boolean;
} }
export interface MediaLocation { export interface MediaLocation {
@@ -16,6 +20,8 @@ export interface MediaLocation {
export type Rating = "" | "s" | "q" | "e"; export type Rating = "" | "s" | "q" | "e";
export interface MediaItem { export interface MediaItem {
id: number;
j_id: string;
md5: string; md5: string;
size: number; size: number;
rating: Rating; rating: Rating;
@@ -24,6 +30,8 @@ export interface MediaItem {
filename: string; filename: string;
extension: string; extension: string;
kind: "image" | "video"; kind: "image" | "video";
uploaded_by: string | null;
uploaded_by_id: number | null;
locations: MediaLocation[]; locations: MediaLocation[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -56,6 +64,7 @@ export interface SystemStatus {
} }
export interface FileNeighbor { export interface FileNeighbor {
j_id: string;
md5: string; md5: string;
filename: string; filename: string;
} }